diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95cd90e..51398d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: go-version: ${{ matrix.go-version }} check-latest: true + - name: Generate mocks + run: make generate-mocks + - name: Run golangci-lint (latest) uses: golangci/golangci-lint-action@v6 with: @@ -46,6 +49,9 @@ jobs: check-latest: true cache: true + - name: Generate mocks + run: make generate-mocks + - name: Verify dependencies run: go mod tidy && git diff --exit-code go.mod go.sum diff --git a/.gitignore b/.gitignore index b826c41..cafaeef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .idea/ -NOTES.md \ No newline at end of file +NOTES.md +mocks/ +*.log +berth \ No newline at end of file diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..b082d26 --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,11 @@ +template: testify + +packages: + github.com/docker/docker/client: + config: + dir: ./mocks/client + filename: "APIClient.go" + pkgname: "client" + structname: "Mock{{.InterfaceName}}" + interfaces: + APIClient: diff --git a/Makefile b/Makefile index b4893ce..f9729e8 100644 --- a/Makefile +++ b/Makefile @@ -15,13 +15,18 @@ run: @echo "Running $(APP_NAME)..." $(GO) run $(APP_PATH) +generate-mocks: + go install github.com/vektra/mockery/v3@latest + export MOCKERY_IN_PACKAGE=true + mockery --config .mockery.yaml + clean: @echo "Cleaning up..." $(GO) clean rm -f $(APP_NAME) @echo "Cleanup complete." -test: +test: generate-mocks @echo "Running tests..." $(GO) test ./... diff --git a/cmd/berth/main.go b/cmd/berth/main.go index cab0859..ed1fce7 100644 --- a/cmd/berth/main.go +++ b/cmd/berth/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" + "log/slog" "os" tea "github.com/charmbracelet/bubbletea" @@ -11,9 +12,43 @@ import ( // main function initializes and runs the Bubble Tea program. func main() { - p := tea.NewProgram(tui.InitialModel(), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) + // Setup logging to a file + logFile, err := os.OpenFile("berth.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + fmt.Printf("Error opening log file: %v\n", err) + os.Exit(1) + } + defer logFile.Close() + + handler := slog.NewTextHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug}) + slog.SetDefault(slog.New(handler)) + + // Recover from panics and log them + defer func() { + if r := recover(); r != nil { + slog.Error("Panic recovered in main defer", "panic", r) + fmt.Printf("Alas, there's been a panic: %v\n", r) + os.Exit(1) + } + }() + + var program *tea.Program + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("Panic recovered during program initialization", "panic", r) + fmt.Printf("Alas, there's been a panic during init: %v\n", r) + os.Exit(1) + } + }() + slog.Debug("Initializing Bubble Tea program...") + program = tea.NewProgram(tui.InitialModel(), tea.WithAltScreen()) + }() + + slog.Debug("Running Bubble Tea program...") + if _, err := program.Run(); err != nil { + slog.Error("Program error", "error", err) + fmt.Printf("Alas, there's been an error: %v\n", err) os.Exit(1) } } diff --git a/go.mod b/go.mod index 569515e..664de74 100644 --- a/go.mod +++ b/go.mod @@ -6,25 +6,57 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 + github.com/docker/docker v28.3.3+incompatible + github.com/opencontainers/image-spec v1.1.1 + github.com/stretchr/testify v1.10.0 ) require ( + github.com/Microsoft/go-winio v0.4.14 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/time v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 03b6a3a..faec9a3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= @@ -18,8 +24,46 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -28,24 +72,122 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/internal/controller/container.go b/internal/controller/container.go index b622703..9abe8dc 100644 --- a/internal/controller/container.go +++ b/internal/controller/container.go @@ -2,12 +2,28 @@ package controller import ( + "context" + "encoding/json" "fmt" + "io" "strings" + "github.com/docker/docker/api/types/container" "github.com/rluders/berth/internal/engine" + "github.com/rluders/berth/internal/service" ) +var containerService service.ContainerService + +func init() { + cli, err := engine.NewClient() + if err != nil { + // Handle error, perhaps log it or panic if it's unrecoverable + panic(fmt.Errorf("failed to create Docker client: %w", err)) + } + containerService = service.NewContainerService(cli) +} + // Container represents a container's simplified information. type Container struct { ID string @@ -21,76 +37,70 @@ type Container struct { // ListContainers lists all running and stopped containers. func ListContainers() ([]Container, error) { - stdout, stderr, err := engine.RunEngineCommand("ps", "-a", "--format", "{{.ID}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\t{{.Ports}}\t{{.Names}}") + containers, err := containerService.ListContainers(context.Background(), container.ListOptions{All: true}) if err != nil { - return nil, fmt.Errorf("failed to list containers: %s, %w", stderr, err) + return nil, fmt.Errorf("failed to list containers: %w", err) } - lines := strings.Split(strings.TrimSpace(stdout), "\n") - var containers []Container - for _, line := range lines { - if line == "" { - continue - } - fields := strings.Split(line, "\t") - if len(fields) != 7 { - // Log or handle malformed line - continue - } - containers = append(containers, Container{ - ID: fields[0], - Image: fields[1], - Command: fields[2], - Created: fields[3], - Status: fields[4], - Ports: fields[5], - Names: fields[6], + var result []Container + for _, c := range containers { + result = append(result, Container{ + ID: c.ID[:12], + Image: c.Image, + Command: c.Command, + Created: fmt.Sprintf("%d", c.Created), + Status: c.Status, + Ports: fmt.Sprintf("%v", c.Ports), + Names: strings.Join(c.Names, ","), }) } - return containers, nil + + return result, nil } // StartContainer starts a container by its ID or name. func StartContainer(idOrName string) error { - _, stderr, err := engine.RunEngineCommand("start", idOrName) - if err != nil { - return fmt.Errorf("failed to start container %s: %s, %w", idOrName, stderr, err) - } - return nil + return containerService.StartContainer(context.Background(), idOrName, container.StartOptions{}) } // StopContainer stops a container by its ID or name. func StopContainer(idOrName string) error { - _, stderr, err := engine.RunEngineCommand("stop", idOrName) - if err != nil { - return fmt.Errorf("failed to stop container %s: %s, %w", idOrName, stderr, err) - } - return nil + return containerService.StopContainer(context.Background(), idOrName, container.StopOptions{}) } // RemoveContainer removes a container by its ID or name. func RemoveContainer(idOrName string) error { - _, stderr, err := engine.RunEngineCommand("rm", idOrName) - if err != nil { - return fmt.Errorf("failed to remove container %s: %s, %w", idOrName, stderr, err) - } - return nil + return containerService.RemoveContainer(context.Background(), idOrName, container.RemoveOptions{}) } // GetContainerLogs retrieves the logs of a container. func GetContainerLogs(idOrName string) (string, error) { - stdout, stderr, err := engine.RunEngineCommand("logs", idOrName) + out, err := containerService.ContainerLogs(context.Background(), idOrName, container.LogsOptions{ShowStdout: true, ShowStderr: true}) + if err != nil { + return "", fmt.Errorf("failed to get logs for container %s: %w", idOrName, err) + } + defer out.Close() + + buf := new(strings.Builder) + _, err = io.Copy(buf, out) if err != nil { - return "", fmt.Errorf("failed to get logs for container %s: %s, %w", idOrName, stderr, err) + return "", fmt.Errorf("failed to read logs for container %s: %w", idOrName, err) } - return stdout, nil + + return buf.String(), nil } // InspectContainer inspects a container by its ID or name and returns its raw JSON output. func InspectContainer(idOrName string) (string, error) { - stdout, stderr, err := engine.RunEngineCommand("inspect", idOrName) + inspect, err := containerService.ContainerInspect(context.Background(), idOrName) if err != nil { - return "", fmt.Errorf("failed to inspect container %s: %s, %w", idOrName, stderr, err) + return "", fmt.Errorf("failed to inspect container %s: %w", idOrName, err) } - return stdout, nil + + jsonBytes, err := json.MarshalIndent(inspect, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal inspect data: %w", err) + } + + return string(jsonBytes), nil } diff --git a/internal/controller/image.go b/internal/controller/image.go index 5165dc4..3cd28ed 100644 --- a/internal/controller/image.go +++ b/internal/controller/image.go @@ -2,12 +2,24 @@ package controller import ( + "context" "fmt" - "strings" + dockerImageTypes "github.com/docker/docker/api/types/image" "github.com/rluders/berth/internal/engine" + "github.com/rluders/berth/internal/service" ) +var imageService service.ImageService + +func init() { + cli, err := engine.NewClient() + if err != nil { + panic(fmt.Errorf("failed to create Docker client: %w", err)) + } + imageService = service.NewImageService(cli) +} + // Image represents an image's simplified information. type Image struct { ID string @@ -19,38 +31,31 @@ type Image struct { // ListImages lists all images. func ListImages() ([]Image, error) { - stdout, stderr, err := engine.RunEngineCommand("images", "--format", "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}") + images, err := imageService.ImageList(context.Background(), dockerImageTypes.ListOptions{}) if err != nil { - return nil, fmt.Errorf("failed to list images: %s, %w", stderr, err) + return nil, fmt.Errorf("failed to list images: %w", err) } - lines := strings.Split(strings.TrimSpace(stdout), "\n") - var images []Image - for _, line := range lines { - if line == "" { - continue + var result []Image + for _, i := range images { + repoTag := "" + if len(i.RepoTags) > 0 { + repoTag = i.RepoTags[0] } - fields := strings.Split(line, "\t") - if len(fields) != 5 { - // Log or handle malformed line - continue - } - images = append(images, Image{ - ID: fields[0], - Repository: fields[1], - Tag: fields[2], - Size: fields[3], - Created: fields[4], + result = append(result, Image{ + ID: i.ID[7:19], + Repository: repoTag, + Tag: repoTag, + Size: fmt.Sprintf("%d", i.Size), + Created: fmt.Sprintf("%d", i.Created), }) } - return images, nil + + return result, nil } // RemoveImage removes an image by its ID or name. func RemoveImage(idOrName string) error { - _, stderr, err := engine.RunEngineCommand("rmi", idOrName) - if err != nil { - return fmt.Errorf("failed to remove image %s: %s, %w", idOrName, stderr, err) - } - return nil + _, err := imageService.ImageRemove(context.Background(), idOrName, dockerImageTypes.RemoveOptions{}) + return err } diff --git a/internal/controller/network.go b/internal/controller/network.go index b6ffdd4..4eced47 100644 --- a/internal/controller/network.go +++ b/internal/controller/network.go @@ -2,12 +2,25 @@ package controller import ( + "context" + "encoding/json" "fmt" - "strings" + "github.com/docker/docker/api/types/network" "github.com/rluders/berth/internal/engine" + "github.com/rluders/berth/internal/service" ) +var networkService service.NetworkService + +func init() { + cli, err := engine.NewClient() + if err != nil { + panic(fmt.Errorf("failed to create Docker client: %w", err)) + } + networkService = service.NewNetworkService(cli) +} + // Network represents a network's simplified information. type Network struct { ID string @@ -18,37 +31,35 @@ type Network struct { // ListNetworks lists all networks. func ListNetworks() ([]Network, error) { - stdout, stderr, err := engine.RunEngineCommand("network", "ls", "--format", "{{.ID}}\t{{.Name}}\t{{.Driver}}\t{{.Scope}}") + networks, err := networkService.NetworkList(context.Background(), network.ListOptions{}) if err != nil { - return nil, fmt.Errorf("failed to list networks: %s, %w", stderr, err) + return nil, fmt.Errorf("failed to list networks: %w", err) } - lines := strings.Split(strings.TrimSpace(stdout), "\n") - var networks []Network - for _, line := range lines { - if line == "" { - continue - } - fields := strings.Split(line, "\t") - if len(fields) != 4 { - // Log or handle malformed line - continue - } - networks = append(networks, Network{ - ID: fields[0], - Name: fields[1], - Driver: fields[2], - Scope: fields[3], + var result []Network + for _, n := range networks { + result = append(result, Network{ + ID: n.ID, + Name: n.Name, + Driver: n.Driver, + Scope: n.Scope, }) } - return networks, nil + + return result, nil } // InspectNetwork inspects a network and returns its raw JSON output. func InspectNetwork(idOrName string) (string, error) { - stdout, stderr, err := engine.RunEngineCommand("network", "inspect", idOrName) + network, err := networkService.NetworkInspect(context.Background(), idOrName, network.InspectOptions{}) if err != nil { - return "", fmt.Errorf("failed to inspect network %s: %s, %w", idOrName, stderr, err) + return "", fmt.Errorf("failed to inspect network %s: %w", idOrName, err) } - return stdout, nil + + jsonBytes, err := json.MarshalIndent(network, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal inspect data: %w", err) + } + + return string(jsonBytes), nil } diff --git a/internal/controller/system.go b/internal/controller/system.go index d16a4a7..17fb32d 100644 --- a/internal/controller/system.go +++ b/internal/controller/system.go @@ -2,12 +2,26 @@ package controller import ( + "context" "fmt" "strings" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" "github.com/rluders/berth/internal/engine" + "github.com/rluders/berth/internal/service" ) +var systemService service.SystemService + +func init() { + cli, err := engine.NewClient() + if err != nil { + panic(fmt.Errorf("failed to create Docker client: %w", err)) + } + systemService = service.NewSystemService(cli) +} + // SystemInfo holds system-wide statistics about containers, images, volumes, and networks. type SystemInfo struct { Containers int // Total number of containers. @@ -17,91 +31,56 @@ type SystemInfo struct { Images int // Total number of images. Volumes int // Total number of volumes. Networks int // Total number of networks. - DiskUsage string // Disk space used by Docker/Podman resources. + DiskUsage string } // GetSystemInfo retrieves system-wide information about containers, images, and volumes. func GetSystemInfo() (SystemInfo, error) { - var info SystemInfo - - // Get container stats - stdout, stderr, err := engine.RunEngineCommand("info", "--format", "{{.Containers}}\t{{.ContainersRunning}}\t{{.ContainersPaused}}\t{{.ContainersStopped}}") - if err != nil { - return info, fmt.Errorf("failed to get container info: %s, %w", stderr, err) - } - fields := strings.Split(strings.TrimSpace(stdout), "\n") - if len(fields) == 4 { - _, _ = fmt.Sscanf(fields[0], "%d", &info.Containers) - _, _ = fmt.Sscanf(fields[1], "%d", &info.Running) - _, _ = fmt.Sscanf(fields[2], "%d", &info.Paused) - _, _ = fmt.Sscanf(fields[3], "%d", &info.Stopped) - } - - // Get image count - stdout, stderr, err = engine.RunEngineCommand("images", "-q") + info, err := systemService.Info(context.Background()) if err != nil { - return info, fmt.Errorf("failed to get image count: %s, %w", stderr, err) + return SystemInfo{}, fmt.Errorf("failed to get info: %w", err) } - info.Images = len(strings.Split(strings.TrimSpace(stdout), "\n")) - // Get volume count - stdout, stderr, err = engine.RunEngineCommand("volume", "ls", "-q") + diskUsage, err := systemService.DiskUsage(context.Background(), types.DiskUsageOptions{}) if err != nil { - return info, fmt.Errorf("failed to get volume count: %s, %w", stderr, err) + return SystemInfo{}, fmt.Errorf("failed to get disk usage: %w", err) } - info.Volumes = len(strings.Split(strings.TrimSpace(stdout), "\n")) - // Get network count - stdout, stderr, err = engine.RunEngineCommand("network", "ls", "-q") - if err != nil { - return info, fmt.Errorf("failed to get network count: %s, %w", stderr, err) - } - info.Networks = len(strings.Split(strings.TrimSpace(stdout), "\n")) - - // Get disk usage (simplified for now, can be improved) - stdout, _, err = engine.RunEngineCommand("system", "df", "--format", "{{.Size}}") - if err != nil { - // This command might not be available in older versions or for Podman in the same way - // Handle gracefully or provide a fallback - info.DiskUsage = "N/A" - } else { - lines := strings.Split(strings.TrimSpace(stdout), "\n") - if len(lines) > 0 { - info.DiskUsage = lines[len(lines)-1] // Last line usually contains total size - } else { - info.DiskUsage = "N/A" - } - } - - return info, nil + return SystemInfo{ + Containers: info.Containers, + Running: info.ContainersRunning, + Paused: info.ContainersPaused, + Stopped: info.ContainersStopped, + Images: info.Images, + Volumes: len(diskUsage.Volumes), + Networks: len(info.DriverStatus), + DiskUsage: fmt.Sprintf("%d", diskUsage.LayersSize), + }, nil } // BasicCleanup removes stopped containers, unused networks, and unused images. func BasicCleanup() (string, error) { var output strings.Builder - // Prune containers - stdout, stderr, err := engine.RunEngineCommand("container", "prune", "-f") + _, err := systemService.ContainersPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune containers: %s\n", stderr)) + output.WriteString(fmt.Sprintf("Failed to prune containers: %s\n", err)) } else { - output.WriteString(fmt.Sprintf("Container prune output:\n%s\n", stdout)) + output.WriteString("Containers pruned successfully\n") } - // Prune networks - stdout, stderr, err = engine.RunEngineCommand("network", "prune", "-f") + _, err = systemService.NetworksPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune networks: %s\n", stderr)) + output.WriteString(fmt.Sprintf("Failed to prune networks: %s\n", err)) } else { - output.WriteString(fmt.Sprintf("Network prune output:\n%s\n", stdout)) + output.WriteString("Networks pruned successfully\n") } - // Prune images - stdout, stderr, err = engine.RunEngineCommand("image", "prune", "-f") + _, err = systemService.ImagesPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune images: %s\n", stderr)) + output.WriteString(fmt.Sprintf("Failed to prune images: %s\n", err)) } else { - output.WriteString(fmt.Sprintf("Image prune output:\n%s\n", stdout)) + output.WriteString("Images pruned successfully\n") } return output.String(), nil @@ -111,20 +90,20 @@ func BasicCleanup() (string, error) { func AdvancedCleanup() (string, error) { var output strings.Builder - // Prune volumes - stdout, stderr, err := engine.RunEngineCommand("volume", "prune", "-f") + _, err := systemService.VolumesPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune volumes: %s\n", stderr)) + output.WriteString(fmt.Sprintf("Failed to prune volumes: %s\n", err)) } else { - output.WriteString(fmt.Sprintf("Volume prune output:\n%s\n", stdout)) + output.WriteString("Volumes pruned successfully\n") } - // Prune dangling images - stdout, stderr, err = engine.RunEngineCommand("image", "prune", "-f", "--filter", "dangling=true") + args := filters.NewArgs() + args.Add("dangling", "true") + _, err = systemService.ImagesPrune(context.Background(), args) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune dangling images: %s\n", stderr)) + output.WriteString(fmt.Sprintf("Failed to prune dangling images: %s\n", err)) } else { - output.WriteString(fmt.Sprintf("Dangling image prune output:\n%s\n", stdout)) + output.WriteString("Dangling images pruned successfully\n") } return output.String(), nil @@ -132,9 +111,25 @@ func AdvancedCleanup() (string, error) { // TotalCleanup prunes all unused containers, images, volumes, and networks. func TotalCleanup() (string, error) { - stdout, stderr, err := engine.RunEngineCommand("system", "prune", "-a", "--volumes", "-f") + _, err := systemService.ContainersPrune(context.Background(), filters.Args{}) if err != nil { - return "", fmt.Errorf("failed to perform total cleanup: %s, %w", stderr, err) + return "", fmt.Errorf("failed to prune containers: %w", err) } - return stdout, nil + + _, err = systemService.NetworksPrune(context.Background(), filters.Args{}) + if err != nil { + return "", fmt.Errorf("failed to prune networks: %w", err) + } + + _, err = systemService.ImagesPrune(context.Background(), filters.Args{}) + if err != nil { + return "", fmt.Errorf("failed to prune images: %w", err) + } + + _, err = systemService.VolumesPrune(context.Background(), filters.Args{}) + if err != nil { + return "", fmt.Errorf("failed to prune volumes: %w", err) + } + + return "Total cleanup performed successfully", nil } diff --git a/internal/controller/volume.go b/internal/controller/volume.go index 0702d02..53803b8 100644 --- a/internal/controller/volume.go +++ b/internal/controller/volume.go @@ -2,12 +2,24 @@ package controller import ( + "context" "fmt" - "strings" + "github.com/docker/docker/api/types/volume" "github.com/rluders/berth/internal/engine" + "github.com/rluders/berth/internal/service" ) +var volumeService service.VolumeService + +func init() { + cli, err := engine.NewClient() + if err != nil { + panic(fmt.Errorf("failed to create Docker client: %w", err)) + } + volumeService = service.NewVolumeService(cli) +} + // Volume represents a volume's simplified information. type Volume struct { Name string @@ -18,37 +30,24 @@ type Volume struct { // ListVolumes lists all volumes. func ListVolumes() ([]Volume, error) { - stdout, stderr, err := engine.RunEngineCommand("volume", "ls", "--format", "{{.Name}}\t{{.Driver}}\t{{.Scope}}\t{{.Mountpoint}}") + volumes, err := volumeService.VolumeList(context.Background(), volume.ListOptions{}) if err != nil { - return nil, fmt.Errorf("failed to list volumes: %s, %w", stderr, err) + return nil, fmt.Errorf("failed to list volumes: %w", err) } - lines := strings.Split(strings.TrimSpace(stdout), "\n") - var volumes []Volume - for _, line := range lines { - if line == "" { - continue - } - fields := strings.Split(line, "\t") - if len(fields) != 4 { - // Log or handle malformed line - continue - } - volumes = append(volumes, Volume{ - Name: fields[0], - Driver: fields[1], - Scope: fields[2], - Mountpoint: fields[3], + var result []Volume + for _, v := range volumes.Volumes { + result = append(result, Volume{ + Name: v.Name, + Driver: v.Driver, + Mountpoint: v.Mountpoint, }) } - return volumes, nil + + return result, nil } // RemoveVolume removes a volume by its name. func RemoveVolume(name string) error { - _, stderr, err := engine.RunEngineCommand("volume", "rm", name) - if err != nil { - return fmt.Errorf("failed to remove volume %s: %s, %w", name, stderr, err) - } - return nil + return volumeService.VolumeRemove(context.Background(), name, false) } diff --git a/internal/engine/client.go b/internal/engine/client.go new file mode 100644 index 0000000..602fa65 --- /dev/null +++ b/internal/engine/client.go @@ -0,0 +1,15 @@ +// Package engine provides functionality for creating a Docker client. +package engine + +import ( + "github.com/docker/docker/client" +) + +// NewClient creates a new Docker client. +func NewClient() (*client.Client, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, err + } + return cli, nil +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 95b340e..b92771f 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -2,10 +2,9 @@ package engine import ( + "bytes" "fmt" "os/exec" - - "github.com/rluders/berth/internal/utils" ) // EngineType represents the type of container engine detected. @@ -13,9 +12,9 @@ type EngineType string const ( // Docker represents the Docker container engine. - Docker EngineType = "docker" + Docker EngineType = "docker" // Podman represents the Podman container engine. - Podman EngineType = "podman" + Podman EngineType = "podman" // Unknown represents an unknown or undetected container engine. Unknown EngineType = "unknown" ) @@ -57,5 +56,10 @@ func RunEngineCommand(args ...string) (string, string, error) { if detectedEngine == Unknown { return "", "", fmt.Errorf("no container engine detected") } - return utils.RunCommand(enginePath, args...) + cmd := exec.Command(enginePath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err } diff --git a/internal/service/container.go b/internal/service/container.go new file mode 100644 index 0000000..e05abd0 --- /dev/null +++ b/internal/service/container.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "fmt" + "io" + + containerTypes "github.com/docker/docker/api/types/container" + dockerClient "github.com/docker/docker/client" +) + +// ContainerService defines the interface for container-related operations. +type ContainerService interface { + ListContainers(ctx context.Context, options containerTypes.ListOptions) ([]containerTypes.Summary, error) + StartContainer(ctx context.Context, containerID string, options containerTypes.StartOptions) error + StopContainer(ctx context.Context, containerID string, options containerTypes.StopOptions) error + RemoveContainer(ctx context.Context, containerID string, options containerTypes.RemoveOptions) error + ContainerLogs(ctx context.Context, containerID string, options containerTypes.LogsOptions) (io.ReadCloser, error) + ContainerInspect(ctx context.Context, containerID string) (containerTypes.InspectResponse, error) +} + +// dockerContainerService is a concrete implementation of ContainerService. +type dockerContainerService struct { + client dockerClient.APIClient +} + +// NewContainerService creates a new ContainerService. +func NewContainerService(client dockerClient.APIClient) ContainerService { + return &dockerContainerService{client: client} +} + +// ListContainers lists all containers. +func (s *dockerContainerService) ListContainers(ctx context.Context, options containerTypes.ListOptions) ([]containerTypes.Summary, error) { + containers, err := s.client.ContainerList(ctx, options) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + return containers, nil +} + +// StartContainer starts a container. +func (s *dockerContainerService) StartContainer(ctx context.Context, containerID string, options containerTypes.StartOptions) error { + return s.client.ContainerStart(ctx, containerID, options) +} + +// StopContainer stops a container. +func (s *dockerContainerService) StopContainer(ctx context.Context, containerID string, options containerTypes.StopOptions) error { + return s.client.ContainerStop(ctx, containerID, options) +} + +// RemoveContainer removes a container. +func (s *dockerContainerService) RemoveContainer(ctx context.Context, containerID string, options containerTypes.RemoveOptions) error { + return s.client.ContainerRemove(ctx, containerID, options) +} + +// ContainerLogs retrieves container logs. +func (s *dockerContainerService) ContainerLogs(ctx context.Context, containerID string, options containerTypes.LogsOptions) (io.ReadCloser, error) { + return s.client.ContainerLogs(ctx, containerID, options) +} + +// ContainerInspect inspects a container. +func (s *dockerContainerService) ContainerInspect(ctx context.Context, containerID string) (containerTypes.InspectResponse, error) { + inspect, err := s.client.ContainerInspect(ctx, containerID) + if err != nil { + return containerTypes.InspectResponse{}, fmt.Errorf("failed to inspect container %s: %w", containerID, err) + } + return inspect, nil +} diff --git a/internal/service/container_test.go b/internal/service/container_test.go new file mode 100644 index 0000000..8046619 --- /dev/null +++ b/internal/service/container_test.go @@ -0,0 +1,449 @@ +package service + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types/container" + dockerClient "github.com/docker/docker/client" + "github.com/rluders/berth/mocks/client" + "github.com/stretchr/testify/mock" + "io" + "reflect" + "strings" + "testing" +) + +// mockReadCloser is a mock implementation of io.ReadCloser for testing +type mockReadCloser struct { + io.Reader +} + +func (m *mockReadCloser) Close() error { + return nil +} + +func newMockReadCloser(s string) io.ReadCloser { + return &mockReadCloser{strings.NewReader(s)} +} + +func TestNewContainerService(t *testing.T) { + type args struct { + client dockerClient.APIClient + } + mockClient := client.NewMockAPIClient(t) + tests := []struct { + name string + args args + want ContainerService + }{ + { + name: "creates new container service", + args: args{ + client: mockClient, + }, + want: &dockerContainerService{ + client: mockClient, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewContainerService(tt.args.client); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewContainerService() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerContainerService_ContainerInspect(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + containerID string + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful container inspect + successResp := container.InspectResponse{} + mockClient.EXPECT().ContainerInspect(mock.Anything, "container123").Return(successResp, nil) + + // Setup failed container inspect + mockClient.EXPECT().ContainerInspect(mock.Anything, "invalid-container").Return(container.InspectResponse{}, fmt.Errorf("container not found")) + + tests := []struct { + name string + fields fields + args args + want container.InspectResponse + wantErr bool + }{ + { + name: "successful container inspect", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "container123", + }, + want: successResp, + wantErr: false, + }, + { + name: "failed container inspect", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "invalid-container", + }, + want: container.InspectResponse{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerContainerService{ + client: tt.fields.client, + } + got, err := s.ContainerInspect(tt.args.ctx, tt.args.containerID) + if (err != nil) != tt.wantErr { + t.Errorf("ContainerInspect() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ContainerInspect() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerContainerService_ContainerLogs(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + containerID string + options container.LogsOptions + } + + mockClient := client.NewMockAPIClient(t) + successLogs := newMockReadCloser("container logs") + + // Setup successful container logs + mockClient.EXPECT().ContainerLogs(mock.Anything, "container123", mock.AnythingOfType("container.LogsOptions")).Return(successLogs, nil) + + // Setup failed container logs + mockClient.EXPECT().ContainerLogs(mock.Anything, "invalid-container", mock.AnythingOfType("container.LogsOptions")).Return(nil, fmt.Errorf("container not found")) + + tests := []struct { + name string + fields fields + args args + want io.ReadCloser + wantErr bool + }{ + { + name: "successful container logs retrieval", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "container123", + options: container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + }, + }, + want: successLogs, + wantErr: false, + }, + { + name: "failed container logs retrieval", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "invalid-container", + options: container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + }, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerContainerService{ + client: tt.fields.client, + } + got, err := s.ContainerLogs(tt.args.ctx, tt.args.containerID, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("ContainerLogs() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ContainerLogs() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerContainerService_ListContainers(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + options container.ListOptions + } + + mockClient := client.NewMockAPIClient(t) + successList := []container.Summary{} + + // Setup successful container list + mockClient.EXPECT().ContainerList(mock.Anything, container.ListOptions{All: true}).Return(successList, nil) + + // Setup failed container list + mockClient.EXPECT().ContainerList(mock.Anything, container.ListOptions{}).Return(nil, fmt.Errorf("failed to list containers")) + + tests := []struct { + name string + fields fields + args args + want []container.Summary + wantErr bool + }{ + { + name: "successful container list", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + options: container.ListOptions{All: true}, + }, + want: successList, + wantErr: false, + }, + { + name: "failed container list", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + options: container.ListOptions{}, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerContainerService{ + client: tt.fields.client, + } + got, err := s.ListContainers(tt.args.ctx, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("ListContainers() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ListContainers() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerContainerService_RemoveContainer(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + containerID string + options container.RemoveOptions + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful container removal + mockClient.EXPECT().ContainerRemove(mock.Anything, "container123", container.RemoveOptions{Force: true}).Return(nil) + + // Setup failed container removal + mockClient.EXPECT().ContainerRemove(mock.Anything, "invalid-container", container.RemoveOptions{}).Return(fmt.Errorf("container not found")) + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "successful container removal", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "container123", + options: container.RemoveOptions{Force: true}, + }, + wantErr: false, + }, + { + name: "failed container removal", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "invalid-container", + options: container.RemoveOptions{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerContainerService{ + client: tt.fields.client, + } + if err := s.RemoveContainer(tt.args.ctx, tt.args.containerID, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("RemoveContainer() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_dockerContainerService_StartContainer(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + containerID string + options container.StartOptions + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful container start + mockClient.EXPECT().ContainerStart(mock.Anything, "container123", container.StartOptions{}).Return(nil) + + // Setup failed container start + mockClient.EXPECT().ContainerStart(mock.Anything, "invalid-container", container.StartOptions{}).Return(fmt.Errorf("container not found")) + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "successful container start", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "container123", + options: container.StartOptions{}, + }, + wantErr: false, + }, + { + name: "failed container start", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "invalid-container", + options: container.StartOptions{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerContainerService{ + client: tt.fields.client, + } + if err := s.StartContainer(tt.args.ctx, tt.args.containerID, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("StartContainer() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_dockerContainerService_StopContainer(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + containerID string + options container.StopOptions + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful container stop + mockClient.EXPECT().ContainerStop(mock.Anything, "container123", container.StopOptions{}).Return(nil) + + // Setup failed container stop + mockClient.EXPECT().ContainerStop(mock.Anything, "invalid-container", container.StopOptions{}).Return(fmt.Errorf("container not found")) + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "successful container stop", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "container123", + options: container.StopOptions{}, + }, + wantErr: false, + }, + { + name: "failed container stop", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + containerID: "invalid-container", + options: container.StopOptions{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerContainerService{ + client: tt.fields.client, + } + if err := s.StopContainer(tt.args.ctx, tt.args.containerID, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("StopContainer() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/service/image.go b/internal/service/image.go new file mode 100644 index 0000000..463681e --- /dev/null +++ b/internal/service/image.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "fmt" + + imageTypes "github.com/docker/docker/api/types/image" + dockerClient "github.com/docker/docker/client" +) + +// ImageService defines the interface for image-related operations. +type ImageService interface { + ImageList(ctx context.Context, options imageTypes.ListOptions) ([]imageTypes.Summary, error) + ImageRemove(ctx context.Context, imageID string, options imageTypes.RemoveOptions) ([]imageTypes.DeleteResponse, error) +} + +// dockerImageService is a concrete implementation of ImageService. +type dockerImageService struct { + client dockerClient.APIClient +} + +// NewImageService creates a new ImageService. +func NewImageService(client dockerClient.APIClient) ImageService { + return &dockerImageService{client: client} +} + +// ImageList lists all images. +func (s *dockerImageService) ImageList(ctx context.Context, options imageTypes.ListOptions) ([]imageTypes.Summary, error) { + images, err := s.client.ImageList(ctx, options) + if err != nil { + return nil, fmt.Errorf("failed to list images: %w", err) + } + return images, nil +} + +// ImageRemove removes an image. +func (s *dockerImageService) ImageRemove(ctx context.Context, imageID string, options imageTypes.RemoveOptions) ([]imageTypes.DeleteResponse, error) { + resp, err := s.client.ImageRemove(ctx, imageID, options) + if err != nil { + return nil, fmt.Errorf("failed to remove image: %w", err) + } + return resp, nil +} diff --git a/internal/service/image_test.go b/internal/service/image_test.go new file mode 100644 index 0000000..454451d --- /dev/null +++ b/internal/service/image_test.go @@ -0,0 +1,181 @@ +package service + +import ( + "context" + "fmt" + imageTypes "github.com/docker/docker/api/types/image" + dockerClient "github.com/docker/docker/client" + "github.com/rluders/berth/mocks/client" + "github.com/stretchr/testify/mock" + "reflect" + "testing" +) + +func TestNewImageService(t *testing.T) { + type args struct { + client dockerClient.APIClient + } + mockClient := client.NewMockAPIClient(t) + tests := []struct { + name string + args args + want ImageService + }{ + { + name: "creates new image service", + args: args{ + client: mockClient, + }, + want: &dockerImageService{ + client: mockClient, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewImageService(tt.args.client); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewImageService() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerImageService_ImageList(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + options imageTypes.ListOptions + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful image list + successList := []imageTypes.Summary{} + mockClient.EXPECT().ImageList(mock.Anything, imageTypes.ListOptions{All: true}).Return(successList, nil) + + // Setup failed image list + mockClient.EXPECT().ImageList(mock.Anything, imageTypes.ListOptions{}).Return(nil, fmt.Errorf("failed to list images")) + + tests := []struct { + name string + fields fields + args args + want []imageTypes.Summary + wantErr bool + }{ + { + name: "successful image list", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + options: imageTypes.ListOptions{All: true}, + }, + want: successList, + wantErr: false, + }, + { + name: "failed image list", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + options: imageTypes.ListOptions{}, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerImageService{ + client: tt.fields.client, + } + got, err := s.ImageList(tt.args.ctx, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("ImageList() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ImageList() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerImageService_ImageRemove(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + imageID string + options imageTypes.RemoveOptions + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful image removal + successResp := []imageTypes.DeleteResponse{{ + Deleted: "sha256:1234567890", + Untagged: "test:latest", + }} + mockClient.EXPECT().ImageRemove(mock.Anything, "image123", imageTypes.RemoveOptions{Force: true}).Return(successResp, nil) + + // Setup failed image removal + mockClient.EXPECT().ImageRemove(mock.Anything, "invalid-image", imageTypes.RemoveOptions{}).Return(nil, fmt.Errorf("image not found")) + + tests := []struct { + name string + fields fields + args args + want []imageTypes.DeleteResponse + wantErr bool + }{ + { + name: "successful image removal", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + imageID: "image123", + options: imageTypes.RemoveOptions{Force: true}, + }, + want: successResp, + wantErr: false, + }, + { + name: "failed image removal", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + imageID: "invalid-image", + options: imageTypes.RemoveOptions{}, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerImageService{ + client: tt.fields.client, + } + got, err := s.ImageRemove(tt.args.ctx, tt.args.imageID, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("ImageRemove() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ImageRemove() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/service/network.go b/internal/service/network.go new file mode 100644 index 0000000..53d2efb --- /dev/null +++ b/internal/service/network.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "fmt" + + networkTypes "github.com/docker/docker/api/types/network" + dockerClient "github.com/docker/docker/client" +) + +// NetworkService defines the interface for network-related operations. +type NetworkService interface { + NetworkList(ctx context.Context, options networkTypes.ListOptions) ([]networkTypes.Summary, error) + NetworkInspect(ctx context.Context, networkID string, options networkTypes.InspectOptions) (networkTypes.Inspect, error) +} + +// dockerNetworkService is a concrete implementation of NetworkService. +type dockerNetworkService struct { + client dockerClient.APIClient +} + +// NewNetworkService creates a new NetworkService. +func NewNetworkService(client dockerClient.APIClient) NetworkService { + return &dockerNetworkService{client: client} +} + +// NetworkList lists all networks. +func (s *dockerNetworkService) NetworkList(ctx context.Context, options networkTypes.ListOptions) ([]networkTypes.Summary, error) { + networks, err := s.client.NetworkList(ctx, options) + if err != nil { + return nil, fmt.Errorf("failed to list networks: %w", err) + } + return networks, nil +} + +// NetworkInspect inspects a network. +func (s *dockerNetworkService) NetworkInspect(ctx context.Context, networkID string, options networkTypes.InspectOptions) (networkTypes.Inspect, error) { + network, err := s.client.NetworkInspect(ctx, networkID, options) + if err != nil { + return networkTypes.Inspect{}, fmt.Errorf("failed to inspect network %s: %w", networkID, err) + } + return network, nil +} diff --git a/internal/service/network_test.go b/internal/service/network_test.go new file mode 100644 index 0000000..707747f --- /dev/null +++ b/internal/service/network_test.go @@ -0,0 +1,197 @@ +package service + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types/filters" + networkTypes "github.com/docker/docker/api/types/network" + dockerClient "github.com/docker/docker/client" + "github.com/rluders/berth/mocks/client" + "github.com/stretchr/testify/mock" + "reflect" + "testing" +) + +func TestNewNetworkService(t *testing.T) { + type args struct { + client dockerClient.APIClient + } + mockClient := client.NewMockAPIClient(t) + tests := []struct { + name string + args args + want NetworkService + }{ + { + name: "creates new network service", + args: args{ + client: mockClient, + }, + want: &dockerNetworkService{ + client: mockClient, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewNetworkService(tt.args.client); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewNetworkService() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerNetworkService_NetworkInspect(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + networkID string + options networkTypes.InspectOptions + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful network inspect + successResp := networkTypes.Inspect{ + ID: "network123", + Name: "test-network", + Driver: "bridge", + Scope: "local", + } + mockClient.EXPECT().NetworkInspect(mock.Anything, "network123", networkTypes.InspectOptions{}).Return(successResp, nil) + + // Setup failed network inspect + mockClient.EXPECT().NetworkInspect(mock.Anything, "invalid-network", networkTypes.InspectOptions{}).Return(networkTypes.Inspect{}, fmt.Errorf("network not found")) + + tests := []struct { + name string + fields fields + args args + want networkTypes.Inspect + wantErr bool + }{ + { + name: "successful network inspect", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + networkID: "network123", + options: networkTypes.InspectOptions{}, + }, + want: successResp, + wantErr: false, + }, + { + name: "failed network inspect", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + networkID: "invalid-network", + options: networkTypes.InspectOptions{}, + }, + want: networkTypes.Inspect{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + s := &dockerNetworkService{ + client: tt.fields.client, + } + got, err := s.NetworkInspect(tt.args.ctx, tt.args.networkID, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("NetworkInspect() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NetworkInspect() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerNetworkService_NetworkList(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + options networkTypes.ListOptions + } + + mockClient := client.NewMockAPIClient(t) + + // Setup successful network list + successList := []networkTypes.Summary{{ + ID: "network123", + Name: "test-network", + Driver: "bridge", + Scope: "local", + }} + mockClient.EXPECT().NetworkList(mock.Anything, networkTypes.ListOptions{}).Return(successList, nil) + + // Setup different options for failed network list + filterArgs := filters.NewArgs() + filterArgs.Add("label", "test=true") + filterArgs.Add("dangling", "true") + filteredOptions := networkTypes.ListOptions{ + Filters: filterArgs, + } + mockClient.EXPECT().NetworkList(mock.Anything, filteredOptions).Return(nil, fmt.Errorf("failed to list networks")) + + tests := []struct { + name string + fields fields + args args + want []networkTypes.Summary + wantErr bool + }{ + { + name: "successful network list", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + options: networkTypes.ListOptions{}, + }, + want: successList, + wantErr: false, + }, + { + name: "failed network list with filters", + fields: fields{ + client: mockClient, + }, + args: args{ + ctx: context.Background(), + options: filteredOptions, + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + s := &dockerNetworkService{ + client: tt.fields.client, + } + got, err := s.NetworkList(tt.args.ctx, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("NetworkList() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NetworkList() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/service/system.go b/internal/service/system.go new file mode 100644 index 0000000..11eccb8 --- /dev/null +++ b/internal/service/system.go @@ -0,0 +1,89 @@ +package service + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/system" + "github.com/docker/docker/api/types/volume" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + dockerClient "github.com/docker/docker/client" +) + +// SystemService defines the interface for system-related operations. +type SystemService interface { + Info(ctx context.Context) (system.Info, error) + DiskUsage(ctx context.Context, options types.DiskUsageOptions) (types.DiskUsage, error) + ContainersPrune(ctx context.Context, pruneFilters filters.Args) (container.PruneReport, error) + NetworksPrune(ctx context.Context, pruneFilters filters.Args) (network.PruneReport, error) + ImagesPrune(ctx context.Context, pruneFilters filters.Args) (image.PruneReport, error) + VolumesPrune(ctx context.Context, pruneFilters filters.Args) (volume.PruneReport, error) +} + +// dockerSystemService is a concrete implementation of SystemService. +type dockerSystemService struct { + client dockerClient.APIClient +} + +// NewSystemService creates a new SystemService. +func NewSystemService(client dockerClient.APIClient) SystemService { + return &dockerSystemService{client: client} +} + +// Info returns information about the Docker system. +func (s *dockerSystemService) Info(ctx context.Context) (system.Info, error) { + info, err := s.client.Info(ctx) + if err != nil { + return system.Info{}, fmt.Errorf("failed to get info: %w", err) + } + return info, nil +} + +// DiskUsage returns disk usage statistics. +func (s *dockerSystemService) DiskUsage(ctx context.Context, options types.DiskUsageOptions) (types.DiskUsage, error) { + diskUsage, err := s.client.DiskUsage(ctx, options) + if err != nil { + return types.DiskUsage{}, fmt.Errorf("failed to get disk usage: %w", err) + } + return diskUsage, nil +} + +// ContainersPrune prunes unused containers. +func (s *dockerSystemService) ContainersPrune(ctx context.Context, pruneFilters filters.Args) (container.PruneReport, error) { + report, err := s.client.ContainersPrune(ctx, pruneFilters) + if err != nil { + return container.PruneReport{}, fmt.Errorf("failed to prune containers: %w", err) + } + return report, nil +} + +// NetworksPrune prunes unused networks. +func (s *dockerSystemService) NetworksPrune(ctx context.Context, pruneFilters filters.Args) (network.PruneReport, error) { + report, err := s.client.NetworksPrune(ctx, pruneFilters) + if err != nil { + return network.PruneReport{}, fmt.Errorf("failed to prune networks: %w", err) + } + return report, nil +} + +// ImagesPrune prunes unused images. +func (s *dockerSystemService) ImagesPrune(ctx context.Context, pruneFilters filters.Args) (image.PruneReport, error) { + report, err := s.client.ImagesPrune(ctx, pruneFilters) + if err != nil { + return image.PruneReport{}, fmt.Errorf("failed to prune images: %w", err) + } + return report, nil +} + +// VolumesPrune prunes unused volumes. +func (s *dockerSystemService) VolumesPrune(ctx context.Context, pruneFilters filters.Args) (volume.PruneReport, error) { + report, err := s.client.VolumesPrune(ctx, pruneFilters) + if err != nil { + return volume.PruneReport{}, fmt.Errorf("failed to prune volumes: %w", err) + } + return report, nil +} diff --git a/internal/service/system_test.go b/internal/service/system_test.go new file mode 100644 index 0000000..40e241f --- /dev/null +++ b/internal/service/system_test.go @@ -0,0 +1,474 @@ +package service + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/system" + "github.com/docker/docker/api/types/volume" + dockerClient "github.com/docker/docker/client" + "github.com/rluders/berth/mocks/client" + "github.com/stretchr/testify/mock" + "reflect" + "testing" +) + +func TestNewSystemService(t *testing.T) { + type args struct { + client dockerClient.APIClient + } + + mockClient := client.NewMockAPIClient(t) + + tests := []struct { + name string + args args + want SystemService + }{ + { + name: "creates new system service", + args: args{ + client: mockClient, + }, + want: &dockerSystemService{ + client: mockClient, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewSystemService(tt.args.client); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewSystemService() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerSystemService_ContainersPrune(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + pruneFilters filters.Args + } + + // Success case + successMock := client.NewMockAPIClient(t) + successReport := container.PruneReport{ + ContainersDeleted: []string{"container1", "container2"}, + SpaceReclaimed: 1024 * 1024 * 10, // 10MB + } + successMock.EXPECT().ContainersPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(successReport, nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().ContainersPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(container.PruneReport{}, fmt.Errorf("failed to prune containers")) + + tests := []struct { + name string + fields fields + args args + want container.PruneReport + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: successReport, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: container.PruneReport{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerSystemService{ + client: tt.fields.client, + } + got, err := s.ContainersPrune(tt.args.ctx, tt.args.pruneFilters) + if (err != nil) != tt.wantErr { + t.Errorf("ContainersPrune() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ContainersPrune() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerSystemService_DiskUsage(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + options types.DiskUsageOptions + } + + // Success case + successMock := client.NewMockAPIClient(t) + successUsage := types.DiskUsage{ + LayersSize: 1024 * 1024 * 100, // 100MB + Images: []*image.Summary{}, + Containers: []*container.Summary{}, + Volumes: []*volume.Volume{}, + } + successMock.EXPECT().DiskUsage(mock.Anything, mock.AnythingOfType("types.DiskUsageOptions")).Return(successUsage, nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().DiskUsage(mock.Anything, mock.AnythingOfType("types.DiskUsageOptions")).Return(types.DiskUsage{}, fmt.Errorf("failed to get disk usage")) + + tests := []struct { + name string + fields fields + args args + want types.DiskUsage + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + options: types.DiskUsageOptions{}, + }, + want: successUsage, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + options: types.DiskUsageOptions{}, + }, + want: types.DiskUsage{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerSystemService{ + client: tt.fields.client, + } + got, err := s.DiskUsage(tt.args.ctx, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("DiskUsage() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("DiskUsage() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerSystemService_ImagesPrune(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + pruneFilters filters.Args + } + + // Success case + successMock := client.NewMockAPIClient(t) + successReport := image.PruneReport{ + ImagesDeleted: []image.DeleteResponse{ + {Deleted: "image1"}, + {Deleted: "image2"}, + }, + SpaceReclaimed: 1024 * 1024 * 50, // 50MB + } + successMock.EXPECT().ImagesPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(successReport, nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().ImagesPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(image.PruneReport{}, fmt.Errorf("failed to prune images")) + + tests := []struct { + name string + fields fields + args args + want image.PruneReport + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: successReport, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: image.PruneReport{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerSystemService{ + client: tt.fields.client, + } + got, err := s.ImagesPrune(tt.args.ctx, tt.args.pruneFilters) + if (err != nil) != tt.wantErr { + t.Errorf("ImagesPrune() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ImagesPrune() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerSystemService_Info(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + } + + // Success case + successMock := client.NewMockAPIClient(t) + successInfo := system.Info{ + ID: "test-id", + Containers: 5, + ContainersRunning: 3, + ContainersPaused: 0, + ContainersStopped: 2, + Images: 10, + } + successMock.EXPECT().Info(mock.Anything).Return(successInfo, nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().Info(mock.Anything).Return(system.Info{}, fmt.Errorf("failed to get info")) + + tests := []struct { + name string + fields fields + args args + want system.Info + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + }, + want: successInfo, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + }, + want: system.Info{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerSystemService{ + client: tt.fields.client, + } + got, err := s.Info(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("Info() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Info() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerSystemService_NetworksPrune(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + pruneFilters filters.Args + } + + // Success case + successMock := client.NewMockAPIClient(t) + successReport := network.PruneReport{ + NetworksDeleted: []string{"network1", "network2"}, + } + successMock.EXPECT().NetworksPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(successReport, nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().NetworksPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(network.PruneReport{}, fmt.Errorf("failed to prune networks")) + + tests := []struct { + name string + fields fields + args args + want network.PruneReport + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: successReport, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: network.PruneReport{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerSystemService{ + client: tt.fields.client, + } + got, err := s.NetworksPrune(tt.args.ctx, tt.args.pruneFilters) + if (err != nil) != tt.wantErr { + t.Errorf("NetworksPrune() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NetworksPrune() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerSystemService_VolumesPrune(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + pruneFilters filters.Args + } + + // Success case + successMock := client.NewMockAPIClient(t) + successReport := volume.PruneReport{ + VolumesDeleted: []string{"volume1", "volume2"}, + SpaceReclaimed: 1024 * 1024 * 20, // 20MB + } + successMock.EXPECT().VolumesPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(successReport, nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().VolumesPrune(mock.Anything, mock.AnythingOfType("filters.Args")).Return(volume.PruneReport{}, fmt.Errorf("failed to prune volumes")) + + tests := []struct { + name string + fields fields + args args + want volume.PruneReport + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: successReport, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + pruneFilters: filters.Args{}, + }, + want: volume.PruneReport{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerSystemService{ + client: tt.fields.client, + } + got, err := s.VolumesPrune(tt.args.ctx, tt.args.pruneFilters) + if (err != nil) != tt.wantErr { + t.Errorf("VolumesPrune() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("VolumesPrune() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/service/volume.go b/internal/service/volume.go new file mode 100644 index 0000000..59b3ccd --- /dev/null +++ b/internal/service/volume.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + "fmt" + + volumeTypes "github.com/docker/docker/api/types/volume" + dockerClient "github.com/docker/docker/client" +) + +// VolumeService defines the interface for volume-related operations. +type VolumeService interface { + VolumeList(ctx context.Context, options volumeTypes.ListOptions) (volumeTypes.ListResponse, error) + VolumeRemove(ctx context.Context, volumeID string, force bool) error +} + +// dockerVolumeService is a concrete implementation of VolumeService. +type dockerVolumeService struct { + client dockerClient.APIClient +} + +// NewVolumeService creates a new VolumeService. +func NewVolumeService(client dockerClient.APIClient) VolumeService { + return &dockerVolumeService{client: client} +} + +// VolumeList lists all volumes. +func (s *dockerVolumeService) VolumeList(ctx context.Context, options volumeTypes.ListOptions) (volumeTypes.ListResponse, error) { + volumes, err := s.client.VolumeList(ctx, options) + if err != nil { + return volumeTypes.ListResponse{}, fmt.Errorf("failed to list volumes: %w", err) + } + return volumes, nil +} + +// VolumeRemove removes a volume. +func (s *dockerVolumeService) VolumeRemove(ctx context.Context, volumeID string, force bool) error { + return s.client.VolumeRemove(ctx, volumeID, force) +} diff --git a/internal/service/volume_test.go b/internal/service/volume_test.go new file mode 100644 index 0000000..d359f5c --- /dev/null +++ b/internal/service/volume_test.go @@ -0,0 +1,184 @@ +package service + +import ( + "context" + "fmt" + "github.com/docker/docker/api/types/volume" + dockerClient "github.com/docker/docker/client" + "github.com/rluders/berth/mocks/client" + "github.com/stretchr/testify/mock" + "reflect" + "testing" +) + +func TestNewVolumeService(t *testing.T) { + type args struct { + client dockerClient.APIClient + } + + mockClient := client.NewMockAPIClient(t) + + tests := []struct { + name string + args args + want VolumeService + }{ + { + name: "creates new volume service", + args: args{ + client: mockClient, + }, + want: &dockerVolumeService{ + client: mockClient, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewVolumeService(tt.args.client); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewVolumeService() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerVolumeService_VolumeList(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + options volume.ListOptions + } + + // Success case + successMock := client.NewMockAPIClient(t) + successResponse := volume.ListResponse{ + Volumes: []*volume.Volume{ + { + Name: "volume1", + Driver: "local", + Mountpoint: "/var/lib/docker/volumes/volume1", + }, + { + Name: "volume2", + Driver: "local", + Mountpoint: "/var/lib/docker/volumes/volume2", + }, + }, + } + successMock.EXPECT().VolumeList(mock.Anything, mock.AnythingOfType("volume.ListOptions")).Return(successResponse, nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().VolumeList(mock.Anything, mock.AnythingOfType("volume.ListOptions")).Return(volume.ListResponse{}, fmt.Errorf("failed to list volumes")) + + tests := []struct { + name string + fields fields + args args + want volume.ListResponse + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + options: volume.ListOptions{}, + }, + want: successResponse, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + options: volume.ListOptions{}, + }, + want: volume.ListResponse{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerVolumeService{ + client: tt.fields.client, + } + got, err := s.VolumeList(tt.args.ctx, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("VolumeList() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("VolumeList() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_dockerVolumeService_VolumeRemove(t *testing.T) { + type fields struct { + client dockerClient.APIClient + } + type args struct { + ctx context.Context + volumeID string + force bool + } + + // Success case + successMock := client.NewMockAPIClient(t) + successMock.EXPECT().VolumeRemove(mock.Anything, "volume1", false).Return(nil) + + // Error case + errorMock := client.NewMockAPIClient(t) + errorMock.EXPECT().VolumeRemove(mock.Anything, "invalid-volume", true).Return(fmt.Errorf("volume not found")) + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "success case", + fields: fields{ + client: successMock, + }, + args: args{ + ctx: context.Background(), + volumeID: "volume1", + force: false, + }, + wantErr: false, + }, + { + name: "error case", + fields: fields{ + client: errorMock, + }, + args: args{ + ctx: context.Background(), + volumeID: "invalid-volume", + force: true, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &dockerVolumeService{ + client: tt.fields.client, + } + if err := s.VolumeRemove(tt.args.ctx, tt.args.volumeID, tt.args.force); (err != nil) != tt.wantErr { + t.Errorf("VolumeRemove() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/tui/container.go b/internal/tui/container.go index 29d3712..ecdeebc 100644 --- a/internal/tui/container.go +++ b/internal/tui/container.go @@ -3,6 +3,8 @@ package tui import ( "fmt" + "log/slog" + tea "github.com/charmbracelet/bubbletea" "github.com/rluders/berth/internal/controller" ) @@ -10,10 +12,13 @@ import ( // fetchContainersCmd is a Bubble Tea command that fetches a list of containers. func fetchContainersCmd() tea.Cmd { return func() tea.Msg { + slog.Debug("fetchContainersCmd: Calling controller.ListContainers...") containers, err := controller.ListContainers() if err != nil { + slog.Error("fetchContainersCmd: Error listing containers", "error", err) return err } + slog.Debug("fetchContainersCmd: Successfully listed containers.") return containers } } @@ -21,10 +26,13 @@ func fetchContainersCmd() tea.Cmd { // startContainerCmd is a Bubble Tea command that starts a container. func startContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { + slog.Debug("startContainerCmd: Calling controller.StartContainer", "idOrName", idOrName) err := controller.StartContainer(idOrName) if err != nil { + slog.Error("startContainerCmd: Error starting container", "idOrName", idOrName, "error", err) return err } + slog.Debug("startContainerCmd: Successfully started container.", "idOrName", idOrName) return statusMsg(fmt.Sprintf("Container %s started.", idOrName)) } } @@ -32,10 +40,13 @@ func startContainerCmd(idOrName string) tea.Cmd { // stopContainerCmd is a Bubble Tea command that stops a container. func stopContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { + slog.Debug("stopContainerCmd: Calling controller.StopContainer", "idOrName", idOrName) err := controller.StopContainer(idOrName) if err != nil { + slog.Error("stopContainerCmd: Error stopping container", "idOrName", idOrName, "error", err) return err } + slog.Debug("stopContainerCmd: Successfully stopped container.", "idOrName", idOrName) return statusMsg(fmt.Sprintf("Container %s stopped.", idOrName)) } } @@ -43,10 +54,13 @@ func stopContainerCmd(idOrName string) tea.Cmd { // removeContainerCmd is a Bubble Tea command that removes a container. func removeContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { + slog.Debug("removeContainerCmd: Calling controller.RemoveContainer", "idOrName", idOrName) err := controller.RemoveContainer(idOrName) if err != nil { + slog.Error("removeContainerCmd: Error removing container", "idOrName", idOrName, "error", err) return err } + slog.Debug("removeContainerCmd: Successfully removed container.", "idOrName", idOrName) return statusMsg(fmt.Sprintf("Container %s removed.", idOrName)) } } @@ -54,10 +68,13 @@ func removeContainerCmd(idOrName string) tea.Cmd { // getLogsCmd is a Bubble Tea command that fetches logs for a container. func getLogsCmd(idOrName string) tea.Cmd { return func() tea.Msg { + slog.Debug("getLogsCmd: Calling controller.GetContainerLogs", "idOrName", idOrName) logs, err := controller.GetContainerLogs(idOrName) if err != nil { + slog.Error("getLogsCmd: Error getting container logs", "idOrName", idOrName, "error", err) return err } + slog.Debug("getLogsCmd: Successfully retrieved container logs.", "idOrName", idOrName) return logs } } @@ -65,10 +82,13 @@ func getLogsCmd(idOrName string) tea.Cmd { // inspectContainerCmd is a Bubble Tea command that inspects a container. func inspectContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { + slog.Debug("inspectContainerCmd: Calling controller.InspectContainer", "idOrName", idOrName) output, err := controller.InspectContainer(idOrName) if err != nil { + slog.Error("inspectContainerCmd: Error inspecting container", "idOrName", idOrName, "error", err) return err } + slog.Debug("inspectContainerCmd: Successfully inspected container.", "idOrName", idOrName) return output } } diff --git a/internal/tui/image.go b/internal/tui/image.go index f351bf6..984b734 100644 --- a/internal/tui/image.go +++ b/internal/tui/image.go @@ -3,6 +3,8 @@ package tui import ( "fmt" + "log/slog" + tea "github.com/charmbracelet/bubbletea" "github.com/rluders/berth/internal/controller" ) @@ -10,10 +12,13 @@ import ( // fetchImagesCmd is a Bubble Tea command that fetches a list of images. func fetchImagesCmd() tea.Cmd { return func() tea.Msg { + slog.Debug("fetchImagesCmd: Calling controller.ListImages...") images, err := controller.ListImages() if err != nil { + slog.Error("fetchImagesCmd: Error listing images", "error", err) return err } + slog.Debug("fetchImagesCmd: Successfully listed images.") return images } } @@ -21,10 +26,13 @@ func fetchImagesCmd() tea.Cmd { // removeImageCmd is a Bubble Tea command that removes an image. func removeImageCmd(idOrName string) tea.Cmd { return func() tea.Msg { + slog.Debug("removeImageCmd: Calling controller.RemoveImage", "idOrName", idOrName) err := controller.RemoveImage(idOrName) if err != nil { + slog.Error("removeImageCmd: Error removing image", "idOrName", idOrName, "error", err) return err } + slog.Debug("removeImageCmd: Successfully removed image.", "idOrName", idOrName) return statusMsg(fmt.Sprintf("Image %s removed.", idOrName)) } } diff --git a/internal/tui/network.go b/internal/tui/network.go index ab56620..5003156 100644 --- a/internal/tui/network.go +++ b/internal/tui/network.go @@ -2,6 +2,8 @@ package tui import ( + "log/slog" + tea "github.com/charmbracelet/bubbletea" "github.com/rluders/berth/internal/controller" ) @@ -9,10 +11,13 @@ import ( // fetchNetworksCmd is a Bubble Tea command that fetches a list of networks. func fetchNetworksCmd() tea.Cmd { return func() tea.Msg { + slog.Debug("fetchNetworksCmd: Calling controller.ListNetworks...") networks, err := controller.ListNetworks() if err != nil { + slog.Error("fetchNetworksCmd: Error listing networks", "error", err) return err } + slog.Debug("fetchNetworksCmd: Successfully listed networks.") return networks } } @@ -20,10 +25,13 @@ func fetchNetworksCmd() tea.Cmd { // inspectNetworkCmd is a Bubble Tea command that inspects a network. func inspectNetworkCmd(idOrName string) tea.Cmd { return func() tea.Msg { + slog.Debug("inspectNetworkCmd: Calling controller.InspectNetwork", "idOrName", idOrName) output, err := controller.InspectNetwork(idOrName) if err != nil { + slog.Error("inspectNetworkCmd: Error inspecting network", "idOrName", idOrName, "error", err) return err } + slog.Debug("inspectNetworkCmd: Successfully inspected network.", "idOrName", idOrName) return output } } diff --git a/internal/tui/system.go b/internal/tui/system.go index 753c717..60756ae 100644 --- a/internal/tui/system.go +++ b/internal/tui/system.go @@ -3,6 +3,8 @@ package tui import ( "fmt" + "log/slog" + tea "github.com/charmbracelet/bubbletea" "github.com/rluders/berth/internal/controller" ) @@ -10,43 +12,55 @@ import ( // fetchSystemInfoCmd is a Bubble Tea command that fetches system information. func fetchSystemInfoCmd() tea.Cmd { return func() tea.Msg { - systemInfo, err := controller.GetSystemInfo() + slog.Debug("fetchSystemInfoCmd: Calling controller.GetSystemInfo...") + info, err := controller.GetSystemInfo() if err != nil { + slog.Error("fetchSystemInfoCmd: Error getting system info", "error", err) return err } - return systemInfo + slog.Debug("fetchSystemInfoCmd: Successfully retrieved system info.") + return info } } // basicCleanupCmd is a Bubble Tea command that performs basic cleanup. func basicCleanupCmd() tea.Cmd { return func() tea.Msg { + slog.Debug("basicCleanupCmd: Calling controller.BasicCleanup...") output, err := controller.BasicCleanup() if err != nil { + slog.Error("basicCleanupCmd: Error during basic cleanup", "error", err) return err } - return statusMsg(fmt.Sprintf("Basic Cleanup completed:\n%s", output)) + slog.Debug("basicCleanupCmd: Basic cleanup completed.", "output", output) + return statusMsg(fmt.Sprintf("Basic cleanup: %s", output)) } } // advancedCleanupCmd is a Bubble Tea command that performs advanced cleanup. func advancedCleanupCmd() tea.Cmd { return func() tea.Msg { + slog.Debug("advancedCleanupCmd: Calling controller.AdvancedCleanup...") output, err := controller.AdvancedCleanup() if err != nil { + slog.Error("advancedCleanupCmd: Error during advanced cleanup", "error", err) return err } - return statusMsg(fmt.Sprintf("Advanced Cleanup completed:\n%s", output)) + slog.Debug("advancedCleanupCmd: Advanced cleanup completed.", "output", output) + return statusMsg(fmt.Sprintf("Advanced cleanup: %s", output)) } } // totalCleanupCmd is a Bubble Tea command that performs total cleanup. func totalCleanupCmd() tea.Cmd { return func() tea.Msg { + slog.Debug("totalCleanupCmd: Calling controller.TotalCleanup...") output, err := controller.TotalCleanup() if err != nil { + slog.Error("totalCleanupCmd: Error during total cleanup", "error", err) return err } - return statusMsg(fmt.Sprintf("Total Cleanup completed:\n%s", output)) + slog.Debug("totalCleanupCmd: Total cleanup completed.", "output", output) + return statusMsg(fmt.Sprintf("Total cleanup: %s", output)) } -} \ No newline at end of file +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 299903f..93d0456 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "fmt" + "log/slog" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" @@ -59,6 +60,7 @@ type Model struct { // InitialModel returns an initialized Model with default values. func InitialModel() Model { + slog.Debug("InitialModel: Initializing containerColumns...") containerColumns := []table.Column{ {Title: "ID", Width: 12}, {Title: "Image", Width: 20}, @@ -69,12 +71,14 @@ func InitialModel() Model { {Title: "Names", Width: 20}, } + slog.Debug("InitialModel: Initializing containerTable...") containerTable := table.New( table.WithColumns(containerColumns), table.WithFocused(true), table.WithHeight(10), ) + slog.Debug("InitialModel: Initializing imageColumns...") imageColumns := []table.Column{ {Title: "ID", Width: 15}, {Title: "Repository", Width: 30}, @@ -83,12 +87,14 @@ func InitialModel() Model { {Title: "Created", Width: 20}, } + slog.Debug("InitialModel: Initializing imageTable...") imageTable := table.New( table.WithColumns(imageColumns), table.WithFocused(false), table.WithHeight(10), ) + slog.Debug("InitialModel: Initializing volumeColumns...") volumeColumns := []table.Column{ {Title: "Name", Width: 30}, {Title: "Driver", Width: 15}, @@ -96,12 +102,14 @@ func InitialModel() Model { {Title: "Mountpoint", Width: 50}, } + slog.Debug("InitialModel: Initializing volumeTable...") volumeTable := table.New( table.WithColumns(volumeColumns), table.WithFocused(false), table.WithHeight(10), ) + slog.Debug("InitialModel: Initializing networkColumns...") networkColumns := []table.Column{ {Title: "ID", Width: 15}, {Title: "Name", Width: 30}, @@ -109,12 +117,14 @@ func InitialModel() Model { {Title: "Scope", Width: 10}, } + slog.Debug("InitialModel: Initializing networkTable...") networkTable := table.New( table.WithColumns(networkColumns), table.WithFocused(false), table.WithHeight(10), ) + slog.Debug("InitialModel: Setting table styles...") s := table.DefaultStyles() s.Header = currentTheme.TableHeaderStyle s.Selected = currentTheme.TableSelectedStyle @@ -123,6 +133,7 @@ func InitialModel() Model { volumeTable.SetStyles(s) networkTable.SetStyles(s) + slog.Debug("InitialModel: Returning Model...") return Model{ engineType: engine.DetectEngine(), currentView: ContainersView, @@ -141,6 +152,7 @@ func InitialModel() Model { // getViewName returns the string representation of the current view. func (m Model) getViewName() string { + slog.Debug("getViewName called") switch m.currentView { case ContainersView: return "Containers" @@ -162,6 +174,7 @@ func (m Model) getViewName() string { // getFooterHelp returns the help text for the current view. func (m Model) getFooterHelp() string { + slog.Debug("getFooterHelp called") switch m.currentView { case ContainersView: return "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System • s:Start • x:Stop • d:Remove • l:Logs • i:Inspect • q:Quit" @@ -183,12 +196,14 @@ func (m Model) getFooterHelp() string { // pushView adds the current view to the stack and sets the new view. func (m *Model) pushView(view ViewType) { + slog.Debug("pushView called", "view", view) m.viewStack = append(m.viewStack, m.currentView) m.currentView = view } // popView removes the current view from the stack and returns to the previous view. func (m *Model) popView() { + slog.Debug("popView called") if len(m.viewStack) > 0 { m.currentView = m.viewStack[len(m.viewStack)-1] m.viewStack = m.viewStack[:len(m.viewStack)-1] @@ -199,5 +214,17 @@ func (m *Model) popView() { // Init initializes the Bubble Tea program. func (m Model) Init() tea.Cmd { - return tea.Batch(fetchContainersCmd(), fetchImagesCmd(), fetchVolumesCmd(), fetchNetworksCmd(), fetchSystemInfoCmd(), m.spinner.Tick) + slog.Debug("Init: Calling fetchContainersCmd...") + cmd1 := fetchContainersCmd() + slog.Debug("Init: Calling fetchImagesCmd...") + cmd2 := fetchImagesCmd() + slog.Debug("Init: Calling fetchVolumesCmd...") + cmd3 := fetchVolumesCmd() + slog.Debug("Init: Calling fetchNetworksCmd...") + cmd4 := fetchNetworksCmd() + slog.Debug("Init: Calling fetchSystemInfoCmd...") + cmd5 := fetchSystemInfoCmd() + slog.Debug("Init: Calling spinner.Tick...") + cmd6 := m.spinner.Tick + return tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6) }