diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25f60af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [ '**' ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: make test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: coverage.out + retention-days: 7 + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --config=configs/golangci-lint.yml --timeout=5m diff --git a/.gitignore b/.gitignore index aaadf73..dad5a74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,38 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins +# Binaries +bin/ *.exe -*.exe~ *.dll *.so *.dylib -# Test binary, built with `go test -c` -*.test - -# Code coverage profiles and other test artifacts +# Test coverage *.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories (remove the comment below to include it) -# vendor/ +coverage.html # Go workspace file go.work -go.work.sum -# env file +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +dist/ +release/ + +# Logs +*.log + +# Environment .env +.env.local -# Editor/IDE -# .idea/ -# .vscode/ +# Reports +test/component/reports/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba0c090 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Oswaldo Montaño + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f2d7cfe --- /dev/null +++ b/Makefile @@ -0,0 +1,101 @@ +.PHONY: build test clean install lint run help + +# Variables +BINARY_NAME=gtool +BUILD_DIR=bin +GO=go +GOFLAGS=-v +LDFLAGS=-ldflags "-X github.com/oswaldo-montano/gtool/internal/cli.Version=$(VERSION) \ + -X github.com/oswaldo-montano/gtool/internal/cli.GitCommit=$(GIT_COMMIT) \ + -X github.com/oswaldo-montano/gtool/internal/cli.BuildDate=$(BUILD_DATE)" + +VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_DATE?=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") + +## help: Show this help message +help: + @echo 'Usage:' + @echo ' make ' + @echo '' + @echo 'Targets:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + +## build: Build the binary +build: + @echo "Building $(BINARY_NAME) $(VERSION)..." + @mkdir -p $(BUILD_DIR) + $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/gtool + +## test-launcher-image: Build the Karate test launcher image +test-launcher-image: + @echo "Building test launcher image..." + docker build -t gtool/test-launcher-back:latest test-launcher/ + +## test: Run tests +test: + @echo "Running tests..." + $(GO) test -v -race -coverprofile=coverage.out ./... + +## test-coverage: Run tests with coverage report +test-coverage: test + @echo "Generating coverage report..." + $(GO) tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + open coverage.html || xdg-open coverage.html || echo "Please open coverage.html manually." + +## clean: Clean build artifacts +clean: + @echo "Cleaning..." + @rm -rf $(BUILD_DIR) + @rm -f coverage.out coverage.html + +## install: Install the binary +install: build + @echo "Installing $(BINARY_NAME)..." + @cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/ + +## lint: Run linters +lint: + @echo "Running linters..." + @which golangci-lint > /dev/null || (echo "golangci-lint not installed" && exit 1) + golangci-lint run --config configs/golangci-lint.yml + +## fmt: Format code +fmt: + @echo "Formatting code..." + $(GO) fmt ./... + goimports -w . + +## mod: Download dependencies +mod: + @echo "Downloading dependencies..." + $(GO) mod download + $(GO) mod tidy + +## run: Run the application +run: build + @echo "Running $(BINARY_NAME)..." + ./$(BUILD_DIR)/$(BINARY_NAME) + +## dev: Run in development mode +dev: + @echo "Running in development mode..." + $(GO) run ./cmd/gtool $3 + +## docker-build: Build Docker image +docker-build: + @echo "Building Docker image..." + docker build -t $(BINARY_NAME):$(VERSION) . + +## release: Build for multiple platforms +release: + @echo "Building releases..." + @mkdir -p $(BUILD_DIR)/releases + GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/releases/$(BINARY_NAME)-linux-amd64 ./cmd/gtool + GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/releases/$(BINARY_NAME)-linux-arm64 ./cmd/gtool + GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/releases/$(BINARY_NAME)-darwin-amd64 ./cmd/gtool + GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/releases/$(BINARY_NAME)-darwin-arm64 ./cmd/gtool + GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/releases/$(BINARY_NAME)-windows-amd64.exe ./cmd/gtool + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..a498e6c --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# GTOOL - Component Testing Orchestrator + +> 🌐 **Language:** English · [Español](docs/readme/README.es.md) + +
+ +[![Go Version](https://img.shields.io/badge/go-1.24+-00ADD8?logo=go)](https://go.dev/) +![Tests](https://img.shields.io/badge/tests-185%20passing-success) +![Pipeline](https://img.shields.io/badge/pipeline-functional-success) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) + +**A Go CLI to orchestrate microservice component tests: spin up mocks, launch the app, run the tests and clean everything up — with a single command.** + +
+ +--- + +## 🎯 What GTOOL does + +GTOOL is a CLI orchestrator for **component testing** of microservices. It stands up a service's external dependencies as mocks, launches the service, runs its test suite against that isolated environment, and tears everything down — reproducibly, with a single command. It replaces hand-managed Docker setups and ad-hoc shell scripts with one typed Go binary featuring structured logging, strict config validation and a plugin architecture. + +```mermaid +graph LR + A[🔧 Mocks] --> B[🚀 App] + B --> C[🧪 Karate Tests] + C --> D[📊 HTML Report] + D --> E[🧹 Cleanup] + + style A fill:#4fc3f7 + style B fill:#66bb6a + style C fill:#ffa726 + style D fill:#ab47bc + style E fill:#ef5350 +``` + +**A single `gtool test` runs the full pipeline:** + +1. **Mocks** — starts the third-party dependencies in Docker. 10 built-in services: PostgreSQL, MySQL, MongoDB, Redis, Kafka, Pub/Sub, Couchbase, GCS, MinIO and Mountebank. +2. **App** — launches the service under test, as a Docker image or as native binaries. +3. **Tests** — runs the Karate (backend API) suite against the app and its mocks. +4. **Report** — generates the Karate HTML report and can open it in the browser. +5. **Cleanup** — always tears down app and mocks, even on failure or Ctrl-C. + +Beyond the pipeline, GTOOL also runs **unit tests** (`gtool unit` — mock generation + Ginkgo) and can drive each phase on its own (`gtool services`, `gtool app`, `gtool test karate`). New mock services plug in through the `ServicePlugin` interface. + +--- + +## 🚀 Installation + +```bash +git clone && cd gtool + +make build # builds ./bin/gtool +./bin/gtool version # verify + +make install # copies the binary to $GOPATH/bin +``` + +> ⚠️ **`make install` copies to `$GOPATH/bin` (`~/go/bin`).** If your terminal can't find `gtool` after installing, that directory is not on your `PATH`. Add it: +> ```bash +> echo 'export PATH="$PATH:$GOPATH/bin"' >> ~/.zshrc # or ~/.bashrc +> source ~/.zshrc && rehash +> ``` + +**Requirements:** Go 1.24+, Docker, Make. + +--- + +## ⚡ Quick Start + +```bash +# 1. Validate the repo configuration +gtool config validate --config component-config.yml + +# 2. Start the mocks only +gtool services up +gtool services status +gtool services down + +# 3. Full pipeline (mocks → app → tests → cleanup) +gtool test +``` + +GTOOL looks for `./component-config.yml` by default. Use `--config ` for another one. + +--- + +## 📖 Commands + +Every command accepts `--config `, `--log-level debug|info|warn|error` and `--verbose`. + +### `gtool config` — configuration +```bash +gtool config validate --config component-config.yml # validates the schema +gtool config show --format yaml # prints the resolved config +gtool config show --format json +``` + +### `gtool services` (alias `s`) — third-party mocks +```bash +gtool services up # starts every mock in the config +gtool s up postgresql kafka # starts specific services +gtool s status # services status +gtool s logs postgresql # logs of a service +gtool s down # stops all +``` + +### `gtool app` — application under test +```bash +gtool app start --docker-image myapp:latest --port 8080 +gtool app status +gtool app logs --tail 100 +gtool app stop +``` + +### `gtool unit` (alias `u`) — unit tests +Generates the mocks from `build-config.yml` (mockgen) and runs the suite with Ginkgo, leaving coverage and the JUnit report in `./coverage`. +```bash +gtool unit +gtool unit --skip-mocks # runs the tests only +gtool unit --build-config build-config.yml +``` + +### `gtool test` — component pipeline +```bash +gtool test # gtool-native pipeline (public images) +gtool test karate # Karate only (mocks and app already up) +gtool test karate --tags "@smoke" --no-open +``` + +### `gtool generate` / `gtool version` +```bash +gtool generate config # generates a sample component-config.yml +gtool version +``` + +--- + +## 🧩 Configuration + +GTOOL uses two files (`.yml` extension preferred; `.yaml` supported): + +### `component-config.yml` — component pipeline +```yaml +version: v1 +app-technology: golang # golang | nodejs | generic +test-launcher: test-launcher-back +third-party: + mocks: [postgresql, pubsub, mountebank] + mock-config: + pubsub: + project-id: my-project + topics: + - topic-id: my-topic + subscription-ids: [my-sub] +``` + +### `build-config.yml` — Go binaries and mocks (for `gtool unit`) +```yaml +version: v5 +build: + binaries: + - name: api + path: cmd/server/main.go +mocks: + - source: internal/service/foo_interface.go + filename: foo_interface.go +``` + +--- + +## 🏗️ Architecture + +```mermaid +graph TB + User[👤 User] --> CLI[CLI - Cobra] + CLI --> Orch[Orchestrator] + Orch --> Mock[Mock Manager] + Orch --> App[App Launcher] + Orch --> Test[Test Runner] + Mock --> Plugins[Service Plugins] + Plugins --> Docker[Docker Client] + App --> Docker + Test --> Docker + + style CLI fill:#b3e5fc + style Orch fill:#81d4fa + style Mock fill:#4fc3f7 + style App fill:#4fc3f7 + style Test fill:#4fc3f7 +``` + +- **Plugins** (`internal/plugin/`): `ServicePlugin` (mocks), `AppLauncher`, `TestExecutor`, registered in a thread-safe `PluginRegistry`. +- **Typed errors** (`pkg/errors`) and **structured logging** with Zap (`pkg/logger`). + +--- + +## 🛠️ Development + +```bash +make build # builds ./bin/gtool +make test # tests with -race +make test-coverage # HTML coverage report +make lint # golangci-lint +make fmt # gofmt + goimports +make clean # cleans artifacts +make help # lists every target +``` + +**Standards:** minimum 80% coverage for new code (95%+ for config/orchestration), table-driven tests, errors from `pkg/errors`, conventional commits. See [CLAUDE.md](CLAUDE.md). + +Integration tests (require Docker) per plugin: +```bash +go test -tags=integration ./internal/plugin/services/... +``` + +--- + +## ⚠️ Known limitations + +- **App→mock networking on Linux (native `gtool test`).** The default pipeline runs the app as a bridge-network container while the mocks publish their ports on the host, so a containerized app cannot reach a mock at `localhost`. It needs a host gateway (`host.docker.internal`, automatic on Docker Desktop, manual on Linux) or a shared Docker network. The `--stable` / `--native` path avoids this by running the app as native processes that reach the mocks over `127.0.0.1` (mountebank runs on the host network). +- **Newer mock plugins are lightly tested.** `redis`, `mongodb`, `mysql` and `minio` currently have unit tests only — no Docker integration tests yet. Treat them as experimental. +- **Karate result is exit-code based.** Pass/fail comes from the launcher's exit code; there is no per-scenario parsing of the `karate-reports` output. + +--- + +## 📦 Status + +Functional end-to-end pipeline: 10 mock plugins, app launcher (Docker and native), Karate runner and orchestration with guaranteed teardown. 185 tests passing. + +| Phase | Status | +|------|--------| +| 1. Foundation (CLI, config, plugins, errors, logging) | ✅ | +| 2. Mocks (6 plugins) | ✅ | +| 3. App Launcher (Docker + native) | ✅ | +| 4. Test Executor (Karate) | ✅ | +| 5. Orchestration (pipeline + teardown) | ✅ | +| 6–7. Advanced features, docs/release | 🔄 | + +--- diff --git a/cmd/gtool/main.go b/cmd/gtool/main.go new file mode 100644 index 0000000..ed6e7a6 --- /dev/null +++ b/cmd/gtool/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/oswaldom-code/gtool/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/configs/golangci-lint.yml b/configs/golangci-lint.yml new file mode 100644 index 0000000..8173107 --- /dev/null +++ b/configs/golangci-lint.yml @@ -0,0 +1,81 @@ +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - gosimple # Simplify code + - govet # Vet examines Go source code + - ineffassign # Detect ineffectual assignments + - staticcheck # Static analysis + - unused # Check for unused code + - gofmt # Check formatting + - goimports # Check imports + - misspell # Check for misspelled words + - gocyclo # Check cyclomatic complexity + + disable: + - errcheck # Too many legitimate ignores in codebase + - revive # Name stuttering not critical for now + - goconst # Repeated strings acceptable in this phase + - gosec # Security checks too strict for dev phase + +linters-settings: + errcheck: + check-blank: true + check-type-assertions: false + + govet: + enable-all: true + disable: + - shadow + - fieldalignment # Disabled: memory optimization not critical for now + + gocyclo: + min-complexity: 20 # Increased from 15 for parseConfig-like functions + + staticcheck: + checks: + - all + - -SA1019 # Ignore deprecated APIs (Docker SDK transition) + - -SA1012 # Ignore nil context in tests + + gosec: + excludes: + - G104 # Audit errors not checked + + revive: + rules: + - name: exported + disabled: false + - name: package-comments + disabled: true + +issues: + exclude-rules: + # Exclude some linters from running on tests files + - path: _test\.go + linters: + - gocyclo + - errcheck + - gosec + - goconst + + # Exclude some staticcheck messages + - linters: + - staticcheck + text: "SA9003:" + + max-issues-per-linter: 0 + max-same-issues: 0 + +output: + formats: + - format: colored-line-number + print-issued-lines: true + print-linter-name: true + +# Minimal settings for now, can be expanded later +severity: + default-severity: warning diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md new file mode 100644 index 0000000..3bba046 --- /dev/null +++ b/docs/readme/README.es.md @@ -0,0 +1,241 @@ +# GTOOL - Component Testing Orchestrator + +> 🌐 **Idioma:** Español · [English](../../README.md) + +
+ +[![Go Version](https://img.shields.io/badge/go-1.24+-00ADD8?logo=go)](https://go.dev/) +![Tests](https://img.shields.io/badge/tests-185%20passing-success) +![Pipeline](https://img.shields.io/badge/pipeline-functional-success) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](../../LICENSE) + +**CLI en Go para orquestar pruebas de componente de microservicios: levanta mocks, lanza la app, corre los tests y limpia todo — con un solo comando.** + +
+ +--- + +## 🎯 Qué hace GTOOL + +GTOOL es un orquestador CLI para **pruebas de componente** de microservicios. Levanta las dependencias externas de un servicio como mocks, lanza el servicio, ejecuta su suite de tests contra ese entorno aislado y lo derriba todo — de forma reproducible y con un solo comando. Reemplaza configuraciones de Docker gestionadas a mano y scripts de shell ad-hoc por un único binario en Go, tipado, con logs estructurados, validación estricta de configuración y una arquitectura de plugins. + +```mermaid +graph LR + A[🔧 Mocks] --> B[🚀 App] + B --> C[🧪 Tests Karate] + C --> D[📊 Reporte HTML] + D --> E[🧹 Limpieza] + + style A fill:#4fc3f7 + style B fill:#66bb6a + style C fill:#ffa726 + style D fill:#ab47bc + style E fill:#ef5350 +``` + +**Un solo `gtool test` corre el pipeline completo:** + +1. **Mocks** — levanta las dependencias de terceros en Docker. 10 servicios incorporados: PostgreSQL, MySQL, MongoDB, Redis, Kafka, Pub/Sub, Couchbase, GCS, MinIO y Mountebank. +2. **App** — lanza el servicio bajo prueba, como imagen Docker o como binarios nativos. +3. **Tests** — ejecuta la suite Karate (API backend) contra la app y sus mocks. +4. **Reporte** — genera el reporte HTML de Karate y puede abrirlo en el navegador. +5. **Limpieza** — derriba app y mocks siempre, incluso ante fallos o Ctrl-C. + +Además del pipeline, GTOOL también corre **tests unitarios** (`gtool unit` — generación de mocks + Ginkgo) y puede ejecutar cada fase por separado (`gtool services`, `gtool app`, `gtool test karate`). Los nuevos servicios mock se conectan mediante la interfaz `ServicePlugin`. + +--- + +## 🚀 Instalación + +```bash +git clone && cd gtool + +make build # compila ./bin/gtool +./bin/gtool version # verifica + +make install # copia el binario a $GOPATH/bin +``` + +> ⚠️ **`make install` copia a `$GOPATH/bin` (`~/go/bin`).** Si tu terminal no encuentra `gtool` tras instalar, ese directorio no está en tu `PATH`. Agrégalo: +> ```bash +> echo 'export PATH="$PATH:$GOPATH/bin"' >> ~/.zshrc # o ~/.bashrc +> source ~/.zshrc && rehash +> ``` + +**Requisitos:** Go 1.24+, Docker, Make. + +--- + +## ⚡ Quick Start + +```bash +# 1. Validar la configuración del repo +gtool config validate --config component-config.yml + +# 2. Levantar solo los mocks +gtool services up +gtool services status +gtool services down + +# 3. Pipeline completo (mocks → app → tests → limpieza) +gtool test +``` + +GTOOL busca por defecto `./component-config.yml`. Usa `--config ` para otro. + +--- + +## 📖 Comandos + +Todos los comandos aceptan `--config `, `--log-level debug|info|warn|error` y `--verbose`. + +### `gtool config` — configuración +```bash +gtool config validate --config component-config.yml # valida el esquema +gtool config show --format yaml # imprime la config resuelta +gtool config show --format json +``` + +### `gtool services` (alias `s`) — mocks de terceros +```bash +gtool services up # levanta todos los mocks de la config +gtool s up postgresql kafka # levanta servicios específicos +gtool s status # estado de los servicios +gtool s logs postgresql # logs de un servicio +gtool s down # detiene todos +``` + +### `gtool app` — aplicación bajo prueba +```bash +gtool app start --docker-image myapp:latest --port 8080 +gtool app status +gtool app logs --tail 100 +gtool app stop +``` + +### `gtool unit` (alias `u`) — tests unitarios +Genera los mocks de `build-config.yml` (mockgen) y corre el suite con Ginkgo, dejando cobertura y reporte JUnit en `./coverage`. +```bash +gtool unit +gtool unit --skip-mocks # solo corre los tests +gtool unit --build-config build-config.yml +``` + +### `gtool test` — pipeline de componente +```bash +gtool test # pipeline nativo de gtool (imágenes públicas) +gtool test karate # solo Karate (mocks y app ya levantados) +gtool test karate --tags "@smoke" --no-open +``` + +### `gtool generate` / `gtool version` +```bash +gtool generate config # genera un component-config.yml de ejemplo +gtool version +``` + +--- + +## 🧩 Configuración + +GTOOL usa dos archivos (extensión `.yml` preferida; `.yaml` soportado): + +### `component-config.yml` — pipeline de componente +```yaml +version: v1 +app-technology: golang # golang | nodejs | generic +test-launcher: test-launcher-back +third-party: + mocks: [postgresql, pubsub, mountebank] + mock-config: + pubsub: + project-id: my-project + topics: + - topic-id: my-topic + subscription-ids: [my-sub] +``` + +### `build-config.yml` — binarios y mocks de Go (para `gtool unit`) +```yaml +version: v5 +build: + binaries: + - name: api + path: cmd/server/main.go +mocks: + - source: internal/service/foo_interface.go + filename: foo_interface.go +``` + +--- + +## 🏗️ Arquitectura + +```mermaid +graph TB + User[👤 Usuario] --> CLI[CLI - Cobra] + CLI --> Orch[Orchestrator] + Orch --> Mock[Mock Manager] + Orch --> App[App Launcher] + Orch --> Test[Test Runner] + Mock --> Plugins[Service Plugins] + Plugins --> Docker[Docker Client] + App --> Docker + Test --> Docker + + style CLI fill:#b3e5fc + style Orch fill:#81d4fa + style Mock fill:#4fc3f7 + style App fill:#4fc3f7 + style Test fill:#4fc3f7 +``` + +- **Plugins** (`internal/plugin/`): `ServicePlugin` (mocks), `AppLauncher`, `TestExecutor`, registrados en un `PluginRegistry` thread-safe. +- **Errores tipados** (`pkg/errors`) y **logging estructurado** con Zap (`pkg/logger`). + +--- + +## 🛠️ Desarrollo + +```bash +make build # compila ./bin/gtool +make test # tests con -race +make test-coverage # reporte HTML de cobertura +make lint # golangci-lint +make fmt # gofmt + goimports +make clean # limpia artefactos +make help # lista todos los targets +``` + +**Estándares:** mínimo 80% de cobertura para código nuevo (95%+ en config/orquestación), tests table-driven, errores de `pkg/errors`, conventional commits. Ver [CLAUDE.md](CLAUDE.md). + +Tests de integración (requieren Docker) por plugin: +```bash +go test -tags=integration ./internal/plugin/services/... +``` + +--- + +## ⚠️ Limitaciones conocidas + +- **Networking app→mock en Linux (`gtool test` nativo).** El pipeline por defecto corre la app como contenedor en red bridge, mientras los mocks publican sus puertos en el host, así que una app en contenedor no alcanza un mock en `localhost`. Necesita un host gateway (`host.docker.internal`, automático en Docker Desktop, manual en Linux) o una red Docker compartida. El path `--stable` / `--native` lo evita corriendo la app como procesos nativos que llegan a los mocks por `127.0.0.1` (mountebank corre en la red del host). +- **Los mocks nuevos están poco probados.** `redis`, `mongodb`, `mysql` y `minio` hoy solo tienen unit tests — aún sin integration tests con Docker. Considéralos experimentales. +- **El resultado de Karate es por exit-code.** El pass/fail viene del exit code del launcher; no hay parsing por escenario del output de `karate-reports`. + +--- + +## 📦 Estado + +Pipeline funcional end-to-end: 10 plugins de mock, lanzador de app (Docker y nativo), runner Karate y orquestación con teardown garantizado. 185 tests en verde. + +| Fase | Estado | +|------|--------| +| 1. Fundamentos (CLI, config, plugins, errores, logging) | ✅ | +| 2. Mocks (6 plugins) | ✅ | +| 3. App Launcher (Docker + nativo) | ✅ | +| 4. Test Executor (Karate) | ✅ | +| 5. Orquestación (pipeline + teardown) | ✅ | +| 6–7. Features avanzadas, docs/release | 🔄 | + +--- + diff --git a/examples/postgres-app/Dockerfile b/examples/postgres-app/Dockerfile new file mode 100644 index 0000000..53c3f81 --- /dev/null +++ b/examples/postgres-app/Dockerfile @@ -0,0 +1,15 @@ +# Build +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /bin/postgres-app . + +# Run +FROM alpine:3.20 +RUN adduser -D -u 10001 app +USER app +COPY --from=build /bin/postgres-app /bin/postgres-app +EXPOSE 8080 +ENTRYPOINT ["/bin/postgres-app"] diff --git a/examples/postgres-app/README.md b/examples/postgres-app/README.md new file mode 100644 index 0000000..8ef43d6 --- /dev/null +++ b/examples/postgres-app/README.md @@ -0,0 +1,58 @@ +# postgres-app — sample application under test + +A minimal Go HTTP service backed by PostgreSQL, used to demonstrate and exercise +the `gtool` pipeline (mock PostgreSQL → app → Karate tests → cleanup). + +## Endpoints + +| Method | Path | Description | +|--------|-----------|--------------------------------------| +| GET | `/health` | `200` when the database is reachable | +| GET | `/users` | List users | +| POST | `/users` | Create a user (`{"name": "..."}`) | + +Configuration is read from environment variables: `DB_HOST`, `DB_PORT`, +`DB_USER`, `DB_PASSWORD`, `DB_NAME`, `PORT`. + +## Run it standalone (no gtool) + +```bash +# Start PostgreSQL with the seed schema +docker run -d --name pg -p 5432:5432 \ + -e POSTGRES_PASSWORD=postgres \ + -v "$PWD/init":/docker-entrypoint-initdb.d \ + postgres:16-alpine + +# Run the app against it +go run . + +curl localhost:8080/health +curl localhost:8080/users +curl -XPOST localhost:8080/users -d '{"name":"Grace Hopper"}' +``` + +## Run it with gtool + +```bash +# 1. Build the app image (gtool runs it as a container) +docker build -t postgres-app:latest . + +# 2. From this directory, run the full pipeline +gtool test --config component-config.yml +``` + +`gtool test` starts the PostgreSQL mock (seeded from `init/`), starts this app, +runs the Karate features in `features/`, and tears everything down. + +## Networking notes + +- **App → mock:** gtool runs the app as a bridge-network container while the + mock publishes `5432` on the host. The app therefore reaches the mock through + the host gateway. On Docker Desktop `host.docker.internal` resolves + automatically; on Linux you currently need `host.docker.internal` mapped to + the host gateway (or run the mock and app on the same Docker network). This is + a known gtool limitation tracked for a future enhancement. +- **Tests → app:** the Karate launcher runs on the host network, so it reaches + the app at `localhost:8080`. +- **Local image:** `postgres-app:latest` only exists locally after + `docker build`; ensure it is built before running `gtool test`. diff --git a/examples/postgres-app/component-config.yml b/examples/postgres-app/component-config.yml new file mode 100644 index 0000000..2152e2f --- /dev/null +++ b/examples/postgres-app/component-config.yml @@ -0,0 +1,34 @@ +version: v1 + +# The application under test: built from the Dockerfile in this directory. +app-technology: golang +app-config: + docker-image: postgres-app:latest + port: 8080 + environment: + # On Linux the app container reaches the host-published mock via the host + # gateway; see README for networking notes. + DB_HOST: host.docker.internal + DB_PORT: "5432" + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: postgres + +# Backend API tests (Karate). +test-launcher: test-launcher-back +test-config: + features-path: ./features + reports-path: ./reports + tags: smoke + +# Mock dependencies started by gtool. +third-party: + mocks: + - postgresql + mock-config: + postgresql: + port: "5432" + user: postgres + password: postgres + database: postgres + scripts-path: ./init diff --git a/examples/postgres-app/features/users.feature b/examples/postgres-app/features/users.feature new file mode 100644 index 0000000..fbfab86 --- /dev/null +++ b/examples/postgres-app/features/users.feature @@ -0,0 +1,25 @@ +@smoke +Feature: Users API backed by PostgreSQL + + Background: + * url 'http://localhost:8080' + + Scenario: the service is healthy + Given path '/health' + When method get + Then status 200 + And match response.status == 'ok' + + Scenario: list the seeded users + Given path '/users' + When method get + Then status 200 + And match response contains { id: 1, name: 'Ada Lovelace' } + And match response contains { id: 2, name: 'Alan Turing' } + + Scenario: create a user + Given path '/users' + And request { name: 'Grace Hopper' } + When method post + Then status 201 + And match response.name == 'Grace Hopper' diff --git a/examples/postgres-app/go.mod b/examples/postgres-app/go.mod new file mode 100644 index 0000000..7615fff --- /dev/null +++ b/examples/postgres-app/go.mod @@ -0,0 +1,5 @@ +module example/postgres-app + +go 1.24 + +require github.com/lib/pq v1.10.9 diff --git a/examples/postgres-app/go.sum b/examples/postgres-app/go.sum new file mode 100644 index 0000000..aeddeae --- /dev/null +++ b/examples/postgres-app/go.sum @@ -0,0 +1,2 @@ +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/examples/postgres-app/init/01-schema.sql b/examples/postgres-app/init/01-schema.sql new file mode 100644 index 0000000..ca7f766 --- /dev/null +++ b/examples/postgres-app/init/01-schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL +); + +INSERT INTO users (name) VALUES ('Ada Lovelace'), ('Alan Turing'); diff --git a/examples/postgres-app/main.go b/examples/postgres-app/main.go new file mode 100644 index 0000000..7da2280 --- /dev/null +++ b/examples/postgres-app/main.go @@ -0,0 +1,155 @@ +// Command postgres-app is a minimal HTTP service backed by PostgreSQL, used as +// a sample application under test for gtool. It exposes a health check and a +// users resource so a component test can exercise the app against the gtool +// PostgreSQL mock. +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/lib/pq" +) + +type user struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type server struct { + db *sql.DB +} + +func main() { + db, err := connect() + if err != nil { + log.Fatalf("database: %v", err) + } + defer db.Close() + + srv := &server{db: db} + mux := http.NewServeMux() + mux.HandleFunc("GET /health", srv.health) + mux.HandleFunc("GET /users", srv.listUsers) + mux.HandleFunc("POST /users", srv.createUser) + + addr := ":" + env("PORT", "8080") + httpSrv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + + go func() { + log.Printf("listening on %s", addr) + if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server: %v", err) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + log.Println("shut down") +} + +// connect opens the database and waits for it to accept connections, since the +// mock may still be starting up. +func connect() (*sql.DB, error) { + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + env("DB_HOST", "localhost"), + env("DB_PORT", "5432"), + env("DB_USER", "postgres"), + env("DB_PASSWORD", "postgres"), + env("DB_NAME", "postgres"), + ) + + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + + const maxAttempts = 30 + for attempt := 1; attempt <= maxAttempts; attempt++ { + if err = db.Ping(); err == nil { + log.Printf("connected to database after %d attempt(s)", attempt) + return db, nil + } + log.Printf("waiting for database (attempt %d/%d): %v", attempt, maxAttempts, err) + time.Sleep(time.Second) + } + return nil, fmt.Errorf("database not reachable: %w", err) +} + +func (s *server) health(w http.ResponseWriter, r *http.Request) { + if err := s.db.PingContext(r.Context()); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *server) listUsers(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.QueryContext(r.Context(), "SELECT id, name FROM users ORDER BY id") + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + defer rows.Close() + + users := []user{} + for rows.Next() { + var u user + if err := rows.Scan(&u.ID, &u.Name); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + users = append(users, u) + } + writeJSON(w, http.StatusOK, users) +} + +func (s *server) createUser(w http.ResponseWriter, r *http.Request) { + var in struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" { + writeError(w, http.StatusBadRequest, errors.New("name is required")) + return + } + + var id int + err := s.db.QueryRowContext(r.Context(), + "INSERT INTO users (name) VALUES ($1) RETURNING id", in.Name).Scan(&id) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusCreated, user{ID: id, Name: in.Name}) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba893a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,70 @@ +module github.com/oswaldom-code/gtool + +go 1.24.9 + +require ( + github.com/docker/docker v27.5.0+incompatible + github.com/docker/go-connections v0.6.0 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/spf13/cobra v1.10.1 + github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.27.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Microsoft/go-winio v0.4.21 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gotest.tools/v3 v3.5.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..54ef402 --- /dev/null +++ b/go.sum @@ -0,0 +1,228 @@ +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/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= +github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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 v27.5.0+incompatible h1:um++2NcQtGRTz5eEgO6aJimo6/JxrTXC941hd05JO6U= +github.com/docker/docker v27.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +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/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +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/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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/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/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +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.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +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 v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/cli/app/app.go b/internal/cli/app/app.go new file mode 100644 index 0000000..e7cdd40 --- /dev/null +++ b/internal/cli/app/app.go @@ -0,0 +1,321 @@ +package app + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + coreApp "github.com/oswaldom-code/gtool/internal/core/app" + "github.com/oswaldom-code/gtool/internal/core/app/nativeapp" + coreConfig "github.com/oswaldom-code/gtool/internal/core/config" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +var ( + cfgFile *string + dockerImage string + appPort int + appEnv map[string]string + logsTail int + nativeMode bool + buildConfigFile string +) + +// configFilePath returns the current --config value, dereferenced at run time +// because the persistent flag is parsed after the command is constructed. +func configFilePath() string { + if cfgFile == nil { + return "" + } + return *cfgFile +} + +// appManager is the subset of *coreApp.DockerManager used by the commands, so +// tests can inject a fake. +type appManager interface { + Start(ctx context.Context, cfg *plugin.AppConfig) error + Stop(ctx context.Context) error + Restart(ctx context.Context, cfg *plugin.AppConfig) error + Status(ctx context.Context) (*coreApp.AppStatus, error) + Logs(ctx context.Context, tail int) ([]string, error) +} + +// appDeps bundles the runtime dependencies a command needs. +type appDeps struct { + manager appManager + close func() error +} + +// depsFactory builds the dependencies; a package variable so tests can replace +// it with a Docker-free implementation. +type depsFactory func(log *zap.Logger) (*appDeps, error) + +var newAppDeps depsFactory = defaultAppDeps + +func defaultAppDeps(log *zap.Logger) (*appDeps, error) { + dockerClient, err := docker.NewClient(log) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + return &appDeps{ + manager: coreApp.NewDockerManager(dockerClient, log), + close: dockerClient.Close, + }, nil +} + +// NewAppCmd builds the `app` command tree. +func NewAppCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "app", + Short: "Manage the application under test", + Long: `Start, stop and inspect the application under test as a Docker container. + +The application is run as a labelled container so separate invocations can +find it again (gtool app status / stop / logs). + +Examples: + gtool app start --docker-image myapp:latest --port 8080 + gtool app status + gtool app logs --tail 50 + gtool app stop`, + } + + cfgFile = configFile + + cmd.AddCommand(newStartCmd(), newStopCmd(), newRestartCmd(), newStatusCmd(), newLogsCmd()) + return cmd +} + +func newStartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Start the application", + RunE: runStart, + } + cmd.Flags().StringVar(&dockerImage, "docker-image", "", "Docker image to run (overrides config)") + cmd.Flags().IntVar(&appPort, "port", 0, "application port (overrides config)") + cmd.Flags().StringToStringVar(&appEnv, "env", nil, "environment variables (KEY=VALUE)") + cmd.Flags().BoolVar(&nativeMode, "native", false, "launch the build-config binaries as native processes (like legacy 'component r')") + cmd.Flags().StringVar(&buildConfigFile, "build-config", "build-config.yml", "path to build-config.yml (with --native)") + return cmd +} + +func newStopCmd() *cobra.Command { + cmd := &cobra.Command{Use: "stop", Short: "Stop the application", RunE: runStop} + cmd.Flags().BoolVar(&nativeMode, "native", false, "stop the native build-config binaries (like legacy 'component p')") + cmd.Flags().StringVar(&buildConfigFile, "build-config", "build-config.yml", "path to build-config.yml (with --native)") + return cmd +} + +func newRestartCmd() *cobra.Command { + cmd := &cobra.Command{Use: "restart", Short: "Restart the application", RunE: runRestart} + cmd.Flags().StringVar(&dockerImage, "docker-image", "", "Docker image to run (overrides config)") + cmd.Flags().IntVar(&appPort, "port", 0, "application port (overrides config)") + cmd.Flags().StringToStringVar(&appEnv, "env", nil, "environment variables (KEY=VALUE)") + return cmd +} + +func newStatusCmd() *cobra.Command { + return &cobra.Command{Use: "status", Aliases: []string{"ps"}, Short: "Show application status", RunE: runStatus} +} + +func newLogsCmd() *cobra.Command { + cmd := &cobra.Command{Use: "logs", Short: "View application logs", RunE: runLogs} + cmd.Flags().IntVar(&logsTail, "tail", 100, "number of lines to show from the end of the logs") + return cmd +} + +func runStart(_ *cobra.Command, _ []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + if nativeMode { + launcher, err := nativeapp.New(log.Logger, buildConfigFile) + if err != nil { + return err + } + if err := launcher.Start(ctx); err != nil { + return err + } + fmt.Printf("✅ Native app started. Use 'gtool app stop --native' to stop it.\n") + return nil + } + + cfg, err := loadConfigOrDefault(configFilePath()) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + deps, err := newAppDeps(log.Logger) + if err != nil { + return err + } + defer deps.close() + + appConfig := buildAppConfig(cfg) + fmt.Printf("🚀 Starting application (%s)...\n", appConfig.DockerImage) + if err := deps.manager.Start(ctx, appConfig); err != nil { + return fmt.Errorf("failed to start application: %w", err) + } + + fmt.Printf("✅ Application started\n") + fmt.Printf("Use 'gtool app status' to check status\n") + return nil +} + +func runStop(_ *cobra.Command, _ []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + if nativeMode { + launcher, err := nativeapp.New(log.Logger, buildConfigFile) + if err != nil { + return err + } + return launcher.Stop(ctx) + } + + deps, err := newAppDeps(log.Logger) + if err != nil { + return err + } + defer deps.close() + + fmt.Println("🛑 Stopping application...") + if err := deps.manager.Stop(ctx); err != nil { + return fmt.Errorf("failed to stop application: %w", err) + } + + fmt.Println("✅ Application stopped") + return nil +} + +func runRestart(_ *cobra.Command, _ []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + cfg, err := loadConfigOrDefault(configFilePath()) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + deps, err := newAppDeps(log.Logger) + if err != nil { + return err + } + defer deps.close() + + fmt.Println("🔄 Restarting application...") + if err := deps.manager.Restart(ctx, buildAppConfig(cfg)); err != nil { + return fmt.Errorf("failed to restart application: %w", err) + } + + fmt.Println("✅ Application restarted") + return nil +} + +func runStatus(_ *cobra.Command, _ []string) error { + ctx := context.Background() + log := zap.NewNop() + + deps, err := newAppDeps(log) + if err != nil { + return err + } + defer deps.close() + + status, err := deps.manager.Status(ctx) + if err != nil { + return fmt.Errorf("failed to get application status: %w", err) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "STATUS\tIMAGE\tPORT") + fmt.Fprintln(w, "------\t-----\t----") + + state := status.State + if status.Running { + state = "running ✓" + } + image := status.Image + if image == "" { + image = "-" + } + port := "-" + if status.Port > 0 { + port = fmt.Sprintf("%d", status.Port) + } + fmt.Fprintf(w, "%s\t%s\t%s\n", state, image, port) + return w.Flush() +} + +func runLogs(_ *cobra.Command, _ []string) error { + ctx := context.Background() + log := zap.NewNop() + + deps, err := newAppDeps(log) + if err != nil { + return err + } + defer deps.close() + + logs, err := deps.manager.Logs(ctx, logsTail) + if err != nil { + return fmt.Errorf("failed to get application logs: %w", err) + } + + for _, line := range logs { + fmt.Println(line) + } + return nil +} + +// buildAppConfig maps the file configuration to a plugin.AppConfig, applying +// command-line flag overrides. +func buildAppConfig(cfg *config.Config) *plugin.AppConfig { + ac := &plugin.AppConfig{ + BinaryName: cfg.AppConfig.BinaryName, + BinaryPath: cfg.AppConfig.BinaryPath, + DockerImage: cfg.AppConfig.DockerImage, + Port: cfg.AppConfig.Port, + Environment: cfg.AppConfig.Environment, + } + + if dockerImage != "" { + ac.DockerImage = dockerImage + } + if appPort > 0 { + ac.Port = appPort + } + if len(appEnv) > 0 { + if ac.Environment == nil { + ac.Environment = make(map[string]string, len(appEnv)) + } + for k, v := range appEnv { + ac.Environment[k] = v + } + } + return ac +} + +func loadConfigOrDefault(cfgFile string) (*config.Config, error) { + if cfgFile != "" { + return coreConfig.LoadConfig(cfgFile) + } + + cfg, err := coreConfig.LoadConfig("") + if err != nil { + return config.DefaultConfig(), nil + } + return cfg, nil +} diff --git a/internal/cli/app/app_test.go b/internal/cli/app/app_test.go new file mode 100644 index 0000000..f300fc1 --- /dev/null +++ b/internal/cli/app/app_test.go @@ -0,0 +1,189 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + coreApp "github.com/oswaldom-code/gtool/internal/core/app" + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" +) + +// fakeManager is a controllable appManager for exercising the RunE bodies. +type fakeManager struct { + startErr error + stopErr error + restartErr error + status *coreApp.AppStatus + statusErr error + logs []string + logsErr error + + startedCfg *plugin.AppConfig + stopped bool +} + +func (f *fakeManager) Start(_ context.Context, cfg *plugin.AppConfig) error { + f.startedCfg = cfg + return f.startErr +} + +func (f *fakeManager) Stop(_ context.Context) error { + f.stopped = true + return f.stopErr +} + +func (f *fakeManager) Restart(_ context.Context, cfg *plugin.AppConfig) error { + f.startedCfg = cfg + return f.restartErr +} + +func (f *fakeManager) Status(_ context.Context) (*coreApp.AppStatus, error) { + return f.status, f.statusErr +} + +func (f *fakeManager) Logs(_ context.Context, _ int) ([]string, error) { + return f.logs, f.logsErr +} + +func injectDeps(t *testing.T, mgr appManager) { + t.Helper() + orig := newAppDeps + t.Cleanup(func() { + newAppDeps = orig + dockerImage = "" + appPort = 0 + appEnv = nil + cfgFile = nil + }) + newAppDeps = func(_ *zap.Logger) (*appDeps, error) { + return &appDeps{manager: mgr, close: func() error { return nil }}, nil + } +} + +func TestNewAppCmd(t *testing.T) { + cmd := NewAppCmd(nil) + + assert.Equal(t, "app", cmd.Name()) + want := map[string]bool{"start": false, "stop": false, "restart": false, "status": false, "logs": false} + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + for name, found := range want { + assert.True(t, found, "expected subcommand %q", name) + } +} + +func TestRunStart(t *testing.T) { + t.Run("starts with image from flag", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{} + injectDeps(t, mgr) + dockerImage = "myapp:latest" + appPort = 8080 + + // runStart reads package-level flag vars; pass nil so command + // construction does not reset them to their defaults. + err := runStart(nil, nil) + + require.NoError(t, err) + require.NotNil(t, mgr.startedCfg) + assert.Equal(t, "myapp:latest", mgr.startedCfg.DockerImage) + assert.Equal(t, 8080, mgr.startedCfg.Port) + }) + + t.Run("propagates start failure", func(t *testing.T) { + cfgFile = nil + injectDeps(t, &fakeManager{startErr: errors.New("boom")}) + dockerImage = "myapp:latest" + + err := runStart(newStartCmd(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to start application") + }) +} + +func TestRunStop(t *testing.T) { + t.Run("stops the app", func(t *testing.T) { + mgr := &fakeManager{} + injectDeps(t, mgr) + + require.NoError(t, runStop(newStopCmd(), nil)) + assert.True(t, mgr.stopped) + }) + + t.Run("propagates stop failure", func(t *testing.T) { + injectDeps(t, &fakeManager{stopErr: errors.New("boom")}) + + err := runStop(newStopCmd(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to stop application") + }) +} + +func TestRunRestart(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{} + injectDeps(t, mgr) + dockerImage = "myapp:latest" + + require.NoError(t, runRestart(nil, nil)) + require.NotNil(t, mgr.startedCfg) + assert.Equal(t, "myapp:latest", mgr.startedCfg.DockerImage) +} + +func TestRunStatus(t *testing.T) { + t.Run("running app", func(t *testing.T) { + injectDeps(t, &fakeManager{status: &coreApp.AppStatus{ + Running: true, State: "running", Image: "myapp:latest", Port: 8080, + }}) + require.NoError(t, runStatus(newStatusCmd(), nil)) + }) + + t.Run("not found", func(t *testing.T) { + injectDeps(t, &fakeManager{status: &coreApp.AppStatus{State: "not-found"}}) + require.NoError(t, runStatus(newStatusCmd(), nil)) + }) + + t.Run("propagates error", func(t *testing.T) { + injectDeps(t, &fakeManager{statusErr: errors.New("boom")}) + require.Error(t, runStatus(newStatusCmd(), nil)) + }) +} + +func TestRunLogs(t *testing.T) { + t.Run("prints logs", func(t *testing.T) { + injectDeps(t, &fakeManager{logs: []string{"line1", "line2"}}) + require.NoError(t, runLogs(newLogsCmd(), nil)) + }) + + t.Run("propagates error", func(t *testing.T) { + injectDeps(t, &fakeManager{logsErr: errors.New("boom")}) + require.Error(t, runLogs(newLogsCmd(), nil)) + }) +} + +func TestBuildAppConfig_FlagOverrides(t *testing.T) { + t.Cleanup(func() { dockerImage = ""; appPort = 0; appEnv = nil }) + + cfg := config.DefaultConfig() + cfg.AppConfig.DockerImage = "base:1" + cfg.AppConfig.Port = 1000 + cfg.AppConfig.Environment = map[string]string{"A": "1"} + + dockerImage = "override:2" + appPort = 2000 + appEnv = map[string]string{"B": "2"} + + ac := buildAppConfig(cfg) + + assert.Equal(t, "override:2", ac.DockerImage) + assert.Equal(t, 2000, ac.Port) + assert.Equal(t, "1", ac.Environment["A"]) + assert.Equal(t, "2", ac.Environment["B"]) +} diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go new file mode 100644 index 0000000..fa2df98 --- /dev/null +++ b/internal/cli/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + coreConfig "github.com/oswaldom-code/gtool/internal/core/config" + "github.com/oswaldom-code/gtool/pkg/config" +) + +// NewConfigCmd creates the config command +func NewConfigCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Configuration management", + Long: `Validate, show, and manage configuration files.`, + } + + // Add subcommands + cmd.AddCommand(newConfigValidateCmd(configFile)) + cmd.AddCommand(newConfigShowCmd(configFile)) + + return cmd +} + +func newConfigValidateCmd(configFile *string) *cobra.Command { + return &cobra.Command{ + Use: "validate", + Short: "Validate configuration file", + Long: `Validate the configuration file against the schema and business rules.`, + RunE: func(cmd *cobra.Command, args []string) error { + loader := coreConfig.NewLoader() + var cfg *config.Config + var err error + + // Use the config file from the global flag + if configFile != nil && *configFile != "" { + cfg, err = loader.Load(*configFile) + } else { + cfg, err = loader.LoadFromPath() + } + + if err != nil { + fmt.Printf("❌ Failed to load configuration: %v\n", err) + return err + } + + fmt.Printf("✓ Configuration loaded successfully\n") + + validator := coreConfig.NewValidator() + if err := validator.Validate(cfg); err != nil { + fmt.Printf("❌ Validation failed:\n%v\n", err) + return err + } + + fmt.Printf("✓ Configuration is valid\n") + fmt.Printf("\nSummary:\n") + fmt.Printf(" Version: %s\n", cfg.Version) + fmt.Printf(" Technology: %s\n", cfg.AppTechnology) + fmt.Printf(" Test Launcher: %s\n", cfg.TestLauncher) + fmt.Printf(" Mock Services: %d configured\n", len(cfg.ThirdParty.Mocks)) + + return nil + }, + } +} + +func newConfigShowCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "Show current configuration", + Long: `Display the current configuration with all resolved values.`, + RunE: func(cmd *cobra.Command, args []string) error { + format, _ := cmd.Flags().GetString("format") + + loader := coreConfig.NewLoader() + var cfg *config.Config + var err error + + // Use the config file from the global flag + if configFile != nil && *configFile != "" { + cfg, err = loader.Load(*configFile) + } else { + cfg, err = loader.LoadFromPath() + } + + if err != nil { + fmt.Printf("Failed to load configuration: %v\n", err) + return err + } + + var output []byte + + switch format { + case "json": + output, err = json.MarshalIndent(cfg, "", " ") + case "yml": + output, err = yaml.Marshal(cfg) + default: + return fmt.Errorf("unsupported format: %s (use 'json' or 'yml')", format) + } + + if err != nil { + return fmt.Errorf("failed to marshal configuration: %w", err) + } + + fmt.Println(string(output)) + return nil + }, + } + + cmd.Flags().StringP("format", "f", "yml", "output format (yml or json)") + + return cmd +} diff --git a/internal/cli/generate/generate.go b/internal/cli/generate/generate.go new file mode 100644 index 0000000..539c776 --- /dev/null +++ b/internal/cli/generate/generate.go @@ -0,0 +1,96 @@ +package generate + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +//go:embed templates/component-config.yml +var configTemplate string + +var ( + outputFile string + force bool +) + +// NewGenerateCmd creates the generate command +func NewGenerateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate", + Aliases: []string{"g"}, + Short: "Generate code and configuration files", + Long: `Generate various files for your project. + +Available generators: + - config: Generate a basic component-config.yml file + +Examples: + gtool generate config # Generate component-config.yml + gtool g config # Short form + gtool g config -o my-config.yml # Custom output file + gtool g config --force # Overwrite existing file`, + } + + // Add subcommands + cmd.AddCommand(newGenerateConfigCmd()) + + return cmd +} + +// newGenerateConfigCmd creates the config subcommand +func newGenerateConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Generate a basic configuration file", + Long: `Generate a basic component-config.yml file with examples. + +The generated file includes: + - Basic structure with all sections + - Comments explaining each option + - Example values for PostgreSQL + - Commented examples for other services + +Examples: + gtool generate config # Create component-config.yml + gtool g config # Short form + gtool g config -o my-config.yml # Custom filename + gtool g config --force # Overwrite if exists`, + RunE: runGenerateConfig, + } + + // Add flags + cmd.Flags().StringVarP(&outputFile, "output", "o", "component-config.yml", "output file path") + cmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing file") + + return cmd +} + +func runGenerateConfig(cmd *cobra.Command, args []string) error { + // Check if file exists + if _, err := os.Stat(outputFile); err == nil && !force { + return fmt.Errorf("file %s already exists. Use --force to overwrite", outputFile) + } + + // Get absolute path + absPath, err := filepath.Abs(outputFile) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Write file using embedded template + if err := os.WriteFile(outputFile, []byte(configTemplate), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + fmt.Printf("✅ Configuration file created: %s\n\n", absPath) + fmt.Println("Next steps:") + fmt.Println(" 1. Edit the configuration file to match your needs") + fmt.Println(" 2. Validate: gtool config validate") + fmt.Println(" 3. Start services: gtool s up") + + return nil +} diff --git a/internal/cli/generate/templates/component-config.yml b/internal/cli/generate/templates/component-config.yml new file mode 100644 index 0000000..cfdb870 --- /dev/null +++ b/internal/cli/generate/templates/component-config.yml @@ -0,0 +1,132 @@ +# GTOOL Configuration File +# This file configures the component testing pipeline +# Documentation: https://github.com/oswaldo-montano/gtool + +# ═══════════════════════════════════════════════════════════ +# VERSION +# ═══════════════════════════════════════════════════════════ +version: v1 + +# ═══════════════════════════════════════════════════════════ +# APPLICATION CONFIGURATION +# ═══════════════════════════════════════════════════════════ +# Technology: golang, nodejs, generic +app-technology: golang + +app-config: + # Binary or application name + binary-name: myapp + + # Path to the binary (for golang/generic) + binary-path: ./bin/myapp + + # Docker image (alternative to binary) + # docker-image: myapp:latest + + # Application port + port: 8080 + + # Environment variables for the application + environment: + LOG_LEVEL: debug + # DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + +# ═══════════════════════════════════════════════════════════ +# TEST CONFIGURATION +# ═══════════════════════════════════════════════════════════ +# Test launcher: test-launcher-back (Karate), test-launcher-front (Cypress) +test-launcher: test-launcher-back + +test-config: + # Test tags to run (e.g., "@smoke", "@integration") + tags: "@integration" + + # Path to feature files + features-path: ./test/features + + # Path for test reports + reports-path: ./test/reports + + # Run tests in parallel + parallel: false + +# ═══════════════════════════════════════════════════════════ +# THIRD-PARTY SERVICES (MOCKS) +# ═══════════════════════════════════════════════════════════ +third-party: + # List of services to start + # Available: postgresql, couchbase, kafka, mountebank, pubsub, gcs + mocks: + - postgresql + # - mountebank + # - kafka + + # Configuration for each service + mock-config: + # PostgreSQL configuration + postgresql: + image: postgres:16-alpine + port: 5432 + user: postgres + password: postgres + database: postgres + # Path to SQL initialization scripts + scripts-path: ./test/component/mocks-data/postgresql + + # Mountebank configuration (commented example) + # mountebank: + # port: 2525 + # imposters-path: ./test/component/mocks-data/mountebank + + # Couchbase configuration (commented example) + # couchbase: + # image: couchbase:community-7.2.0 + # bucket: test-bucket + # username: Administrator + # password: password + + # Kafka configuration (commented example) + # kafka: + # image: confluentinc/cp-kafka:latest + # port: 9092 + # topics: + # - test-topic + # - events-topic + +# ═══════════════════════════════════════════════════════════ +# ORCHESTRATION SETTINGS +# ═══════════════════════════════════════════════════════════ +orchestration: + # Start mocks in parallel (faster) or sequential (safer) + parallel-mocks: true + + # Maximum time to wait for services to start + startup-timeout: 180s + + # Interval between health checks + health-check-interval: 3s + + # Maximum number of health check retries + health-check-retries: 60 + + # Clean up services if startup fails + cleanup-on-failure: true + + # Preserve logs after cleanup + preserve-logs: true + +# ═══════════════════════════════════════════════════════════ +# OBSERVABILITY +# ═══════════════════════════════════════════════════════════ +observability: + # Use structured JSON logs + structured-logs: false + + # Log level: debug, info, warn, error + log-level: info + + # Enable metrics collection + metrics-enabled: true + + # Report format: text, json + report-format: text diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..f3007fb --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,122 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "go.uber.org/zap" + + "github.com/oswaldom-code/gtool/internal/cli/app" + "github.com/oswaldom-code/gtool/internal/cli/config" + "github.com/oswaldom-code/gtool/internal/cli/generate" + "github.com/oswaldom-code/gtool/internal/cli/services" + "github.com/oswaldom-code/gtool/internal/cli/test" + "github.com/oswaldom-code/gtool/internal/cli/unit" +) + +var ( + cfgFile string + verbose bool + logLevel string +) + +var rootCmd = &cobra.Command{ + Use: "gtool", + Short: "GTOOL - Component testing orchestrator", + Long: `GTOOL is a CLI tool for orchestrating microservice component tests. + +It automates the complete testing pipeline: + - Launches mock services (Couchbase, PostgreSQL, Kafka, Pub/Sub, GCS, Mountebank) + - Starts your microservice (Go, Node.js, or any technology) + - Executes automated tests (Karate, Cypress) + - Generates detailed reports + - Cleans up everything automatically + +All with a single command: gtool test`, + Version: "0.1.0", +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./component-config.yml)") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level (debug, info, warn, error)") + + checkError(viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))) + checkError(viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))) + + // Register subcommands + rootCmd.AddCommand(generate.NewGenerateCmd()) + rootCmd.AddCommand(services.NewServicesCmd(&cfgFile)) + rootCmd.AddCommand(config.NewConfigCmd(&cfgFile)) + rootCmd.AddCommand(app.NewAppCmd(&cfgFile)) + rootCmd.AddCommand(test.NewTestCmd(&cfgFile)) + rootCmd.AddCommand(unit.NewUnitCmd()) +} + +func initLogger() { + level := viper.GetString("log-level") + var cfg zap.Config + + if level == "debug" { + cfg = zap.NewDevelopmentConfig() + } else { + cfg = zap.NewProductionConfig() + } + + err := cfg.Level.UnmarshalText([]byte(level)) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid log level: %v\n", err) + os.Exit(1) + } + + logger, err := cfg.Build() + if err != nil { + fmt.Fprintf(os.Stderr, "Could not initialize logger: %v\n", err) + os.Exit(1) + } + zap.ReplaceGlobals(logger) +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + viper.AddConfigPath(".") + viper.SetConfigName("component-config") + viper.SetConfigType("yaml") + } + + viper.SetEnvPrefix("GTOOL") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + err := viper.ReadInConfig() + if err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + if !errors.As(err, &configFileNotFoundError) { + _, ok := fmt.Fprintf(os.Stderr, "Config file error: %v\n", err) + if ok != nil { + return + } + os.Exit(1) + } + } +} + +func checkError(err error) { + if err != nil { + panic(err) + } +} + +func Execute() error { + initLogger() + return rootCmd.Execute() +} diff --git a/internal/cli/services/services.go b/internal/cli/services/services.go new file mode 100644 index 0000000..54bb25a --- /dev/null +++ b/internal/cli/services/services.go @@ -0,0 +1,509 @@ +package services + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + coreConfig "github.com/oswaldom-code/gtool/internal/core/config" + "github.com/oswaldom-code/gtool/internal/core/mock" + "github.com/oswaldom-code/gtool/internal/core/mock/stablemocks" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + pluginServices "github.com/oswaldom-code/gtool/internal/plugin/services" + "github.com/oswaldom-code/gtool/pkg/config" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +var ( + followLogs bool + allLogs bool + tailLines int + stableMode bool + cfgFile *string +) + +// configFilePath returns the current --config value, dereferenced at run time +// because the persistent flag is parsed after the command is constructed. +func configFilePath() string { + if cfgFile == nil { + return "" + } + return *cfgFile +} + +// serviceManager is the subset of *mock.Manager used by the commands. Depending +// on the interface (instead of the concrete type) lets tests inject a fake. +type serviceManager interface { + Start(ctx context.Context, name string, config map[string]interface{}) error + Stop(ctx context.Context, name string) error + ListRunning() []string + GetAllStatuses(ctx context.Context) []*mock.ServiceStatus + GetLogs(ctx context.Context, name string, opts *plugin.LogOptions) ([]string, error) +} + +// serviceDeps bundles the runtime dependencies a command needs. ping and close +// expose the underlying Docker client without leaking it to the command bodies. +type serviceDeps struct { + manager serviceManager + ping func(ctx context.Context) error + close func() error +} + +// depsFactory builds the dependencies for a command run. It is a package +// variable so tests can replace it with a Docker-free implementation. +type depsFactory func(cfg *config.Config, log *zap.Logger) (*serviceDeps, error) + +var newServiceDeps depsFactory = defaultServiceDeps + +// defaultServiceDeps wires the real Docker client, plugin registry and mock +// manager together. +func defaultServiceDeps(cfg *config.Config, log *zap.Logger) (*serviceDeps, error) { + dockerClient, err := docker.NewClient(log) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + + registry := plugin.NewRegistry() + if err := pluginServices.RegisterAll(registry, dockerClient, log); err != nil { + _ = dockerClient.Close() + return nil, fmt.Errorf("failed to register plugins: %w", err) + } + + return &serviceDeps{ + manager: mock.NewManager(registry, log, cfg.Orchestration, dockerClient), + ping: dockerClient.Ping, + close: dockerClient.Close, + }, nil +} + +func NewServicesCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "services", + Aliases: []string{"s"}, + Short: "Manage mock services (PostgreSQL, Kafka, etc.)", + Long: `Manage mock services lifecycle. + +Services are Docker containers that provide dependencies for testing: + - PostgreSQL + - Couchbase + - Kafka + - Mountebank + - Pub/Sub + - GCS + +Examples: + gtool services up # Start all configured services + gtool s up postgresql # Start only PostgreSQL + gtool s down # Stop all services + gtool s status # Show services status + gtool s logs postgresql # View PostgreSQL logs`, + } + + // Store reference to config file + cfgFile = configFile + + // Create subcommands + upCmd := newServicesUpCmd() + downCmd := newServicesDownCmd() + statusCmd := newServicesStatusCmd() + logsCmd := newServicesLogsCmd() + + // Add subcommands + cmd.AddCommand(upCmd, downCmd, statusCmd, logsCmd) + + return cmd +} + +func newServicesUpCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "up [service...]", + Short: "Start mock services", + Long: `Start one or more mock services. + +If no services are specified, starts all services defined in the configuration file. + +Examples: + gtool services up # Start all services from config + gtool s up postgresql # Start only PostgreSQL + gtool s up postgresql kafka # Start PostgreSQL and Kafka + gtool s up --config my-config.yml # Use specific config file + gtool s up --stable # Start the fixed-contract mocks seeded from test/component/mocks-data`, + RunE: runServicesUp, + } + cmd.Flags().BoolVar(&stableMode, "stable", false, "use the fixed docker contract (public images, ports, mounts, seeding) for the component mocks") + return cmd +} + +func newServicesDownCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "down [service...]", + Short: "Stop mock services", + Long: `Stop one or more running mock services. + +If no services are specified, stops all running services. + +Examples: + gtool services down # Stop all services + gtool s down postgresql # Stop only PostgreSQL + gtool s down postgresql kafka # Stop PostgreSQL and Kafka + gtool s down --stable # Stop the fixed-contract mock containers`, + RunE: runServicesDown, + } + cmd.Flags().BoolVar(&stableMode, "stable", false, "stop the fixed-contract mock containers") + return cmd +} + +func newServicesStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Aliases: []string{"ps"}, + Short: "Show services status", + Long: `Display the status of all managed services. + +Shows which services are running, stopped, or in error state. + +Examples: + gtool services status # Show all services + gtool s status # Short form + gtool s ps # Docker-like alias`, + RunE: runServicesStatus, + } +} + +func newServicesLogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs [service]", + Short: "View service logs", + Long: `Display logs from a service. + +If no service is specified, shows logs from all services. + +Examples: + gtool services logs postgresql # View PostgreSQL logs + gtool s logs postgresql -f # Follow PostgreSQL logs + gtool s logs --all # View all services logs + gtool s logs postgresql --tail 100 # Last 100 lines`, + RunE: runServicesLogs, + } + + // Add flags + cmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow log output") + cmd.Flags().BoolVar(&allLogs, "all", false, "show logs from all services") + cmd.Flags().IntVar(&tailLines, "tail", 100, "number of lines to show from the end of the logs") + + return cmd +} + +func runServicesUp(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + cfg, err := loadConfigOrDefault(configFilePath()) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + if stableMode { + return runStableUp(ctx, log.Logger, args, cfg) + } + + deps, err := newServiceDeps(cfg, log.Logger) + if err != nil { + return err + } + defer deps.close() + + if err := deps.ping(ctx); err != nil { + return fmt.Errorf("Docker daemon not available: %w", err) + } + + mockManager := deps.manager + + servicesToStart := args + if len(servicesToStart) == 0 { + servicesToStart = cfg.ThirdParty.Mocks + } + + if len(servicesToStart) == 0 { + return fmt.Errorf("no services specified and no services configured") + } + + log.Info("starting services", zap.Strings("services", servicesToStart)) + + for _, serviceName := range servicesToStart { + fmt.Printf("🚀 Starting %s...\n", serviceName) + serviceConfig := cfg.ThirdParty.MockConfig[serviceName] + var configMap map[string]interface{} + if serviceConfig == nil { + configMap = make(map[string]interface{}) + } else { + var ok bool + configMap, ok = serviceConfig.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid configuration for service %s", serviceName) + } + } + + if err := mockManager.Start(ctx, serviceName, configMap); err != nil { + return fmt.Errorf("failed to start %s: %w", serviceName, err) + } + + fmt.Printf("✅ %s started successfully\n", serviceName) + } + + fmt.Printf("\n✨ All services started!\n\n") + fmt.Printf("Use 'gtool s status' to check services status\n") + fmt.Printf("Use 'gtool s logs ' to view logs\n") + fmt.Printf("Use 'gtool s down' to stop all services\n") + + return nil +} + +func runServicesDown(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + cfg, err := loadConfigOrDefault(configFilePath()) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + if stableMode { + return runStableDown(ctx, log.Logger, args) + } + + deps, err := newServiceDeps(cfg, log.Logger) + if err != nil { + return err + } + defer deps.close() + + mockManager := deps.manager + servicesToStop := args + if len(servicesToStop) == 0 { + servicesToStop = mockManager.ListRunning() + } + + if len(servicesToStop) == 0 { + fmt.Println("No services to stop") + return nil + } + + log.Info("stopping services", zap.Strings("services", servicesToStop)) + + // Stop services + for _, serviceName := range servicesToStop { + fmt.Printf("🛑 Stopping %s...\n", serviceName) + + if err := mockManager.Stop(ctx, serviceName); err != nil { + fmt.Printf("⚠️ Failed to stop %s: %v\n", serviceName, err) + continue + } + + fmt.Printf("✅ %s stopped\n", serviceName) + } + + fmt.Printf("\n✨ Services stopped\n") + + return nil +} + +func runServicesStatus(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := zap.NewNop() // Silent logger for status + + cfg, err := loadConfigOrDefault(configFilePath()) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + deps, err := newServiceDeps(cfg, log) + if err != nil { + return err + } + defer deps.close() + + mockManager := deps.manager + + statuses := mockManager.GetAllStatuses(ctx) + + if len(statuses) == 0 { + fmt.Println("No services found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "SERVICE\tSTATUS\tUPTIME\tPORT") + fmt.Fprintln(w, "-------\t------\t------\t----") + + for _, status := range statuses { + uptimeStr := "-" + if status.Uptime > 0 { + uptimeStr = formatDuration(status.Uptime) + } + + portStr := "-" + if status.Port > 0 { + portStr = fmt.Sprintf("%d", status.Port) + } + + statusIcon := "●" + statusColor := status.Status + if status.Status == "running" { + statusColor = "running ✓" + } else if status.Status == "stopped" { + statusColor = "stopped ●" + } else if status.Status == "error" { + statusColor = "error ✗" + } + + fmt.Fprintf(w, "%s\t%s %s\t%s\t%s\n", + status.Name, statusIcon, statusColor, uptimeStr, portStr) + } + + w.Flush() + + return nil +} + +func runServicesLogs(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := zap.NewNop() // Silent logger for status + + if len(args) == 0 && !allLogs { + return fmt.Errorf("please specify a service or use --all flag") + } + + cfg, err := loadConfigOrDefault(configFilePath()) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + deps, err := newServiceDeps(cfg, log) + if err != nil { + return err + } + defer deps.close() + + mockManager := deps.manager + + servicesToLog := args + if allLogs { + servicesToLog = mockManager.ListRunning() + } + + if len(servicesToLog) == 0 { + return fmt.Errorf("no running services found") + } + + for _, serviceName := range servicesToLog { + logs, err := mockManager.GetLogs(ctx, serviceName, &plugin.LogOptions{ + Tail: tailLines, + Follow: followLogs, + }) + + if err != nil { + fmt.Printf("Failed to get logs for %s: %v\n", serviceName, err) + continue + } + + if len(servicesToLog) > 1 { + fmt.Printf("\n=== %s ===\n", serviceName) + } + + for _, line := range logs { + fmt.Println(line) + } + } + + return nil +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} + +// newStableLauncher builds a Docker-backed STABLE-mode launcher and verifies +// the Docker daemon is reachable. The caller is responsible for closing the +// returned client. +func newStableLauncher(log *zap.Logger) (*stablemocks.Launcher, *docker.Client, error) { + dockerClient, err := docker.NewClient(log) + if err != nil { + return nil, nil, fmt.Errorf("failed to create Docker client: %w", err) + } + if err := dockerClient.Ping(context.Background()); err != nil { + _ = dockerClient.Close() + return nil, nil, fmt.Errorf("Docker daemon not available: %w", err) + } + launcher, err := stablemocks.New(dockerClient, log) + if err != nil { + _ = dockerClient.Close() + return nil, nil, err + } + return launcher, dockerClient, nil +} + +func runStableUp(ctx context.Context, log *zap.Logger, args []string, cfg *config.Config) error { + launcher, dockerClient, err := newStableLauncher(log) + if err != nil { + return err + } + defer dockerClient.Close() + + if err := launcher.Up(ctx, args, cfg); err != nil { + return err + } + + fmt.Printf("\n✨ STABLE mocks started! Use 'gtool s down --stable' to stop them.\n") + return nil +} + +func runStableDown(ctx context.Context, log *zap.Logger, args []string) error { + launcher, dockerClient, err := newStableLauncher(log) + if err != nil { + return err + } + defer dockerClient.Close() + + if err := launcher.Down(ctx, args); err != nil { + return err + } + + fmt.Println("✨ STABLE mocks stopped") + return nil +} + +func loadConfigOrDefault(cfgFile string) (*config.Config, error) { + if cfgFile != "" { + cfg, err := coreConfig.LoadConfig(cfgFile) + if err != nil { + return nil, err + } + return cfg, nil + } + + cfg, err := coreConfig.LoadConfig("") + if err != nil { + fmt.Println("ℹ️ No configuration file found, using defaults") + return config.DefaultConfig(), nil + } + + return cfg, nil +} diff --git a/internal/cli/services/services_test.go b/internal/cli/services/services_test.go new file mode 100644 index 0000000..aba439d --- /dev/null +++ b/internal/cli/services/services_test.go @@ -0,0 +1,312 @@ +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/oswaldom-code/gtool/internal/core/mock" + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" +) + +// fakeManager is a controllable serviceManager for exercising the RunE bodies +// without a real Docker client. +type fakeManager struct { + startErr error + stopErr error + running []string + statuses []*mock.ServiceStatus + logs []string + logsErr error + startedSvc []string + stoppedSvc []string +} + +func (f *fakeManager) Start(_ context.Context, name string, _ map[string]interface{}) error { + f.startedSvc = append(f.startedSvc, name) + return f.startErr +} + +func (f *fakeManager) Stop(_ context.Context, name string) error { + f.stoppedSvc = append(f.stoppedSvc, name) + return f.stopErr +} + +func (f *fakeManager) ListRunning() []string { return f.running } + +func (f *fakeManager) GetAllStatuses(_ context.Context) []*mock.ServiceStatus { return f.statuses } + +func (f *fakeManager) GetLogs(_ context.Context, _ string, _ *plugin.LogOptions) ([]string, error) { + return f.logs, f.logsErr +} + +// injectDeps replaces the package factory with one returning the given manager +// and restores the original after the test. +func injectDeps(t *testing.T, mgr serviceManager, pingErr error) { + t.Helper() + orig := newServiceDeps + t.Cleanup(func() { newServiceDeps = orig }) + newServiceDeps = func(_ *config.Config, _ *zap.Logger) (*serviceDeps, error) { + return &serviceDeps{ + manager: mgr, + ping: func(context.Context) error { return pingErr }, + close: func() error { return nil }, + }, nil + } +} + +func TestNewServicesCmd(t *testing.T) { + cmd := NewServicesCmd(nil) + + assert.Equal(t, "services", cmd.Name()) + assert.Contains(t, cmd.Aliases, "s") + + want := map[string]bool{"up": false, "down": false, "status": false, "logs": false} + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + for name, found := range want { + assert.True(t, found, "expected subcommand %q to be registered", name) + } +} + +func TestNewServicesCmd_StoresConfigFile(t *testing.T) { + file := "my-config.yml" + _ = NewServicesCmd(&file) + assert.Equal(t, "my-config.yml", configFilePath()) +} + +func TestServicesLogsCmd_Flags(t *testing.T) { + cmd := newServicesLogsCmd() + + require.NotNil(t, cmd.Flags().Lookup("follow")) + require.NotNil(t, cmd.Flags().Lookup("all")) + require.NotNil(t, cmd.Flags().Lookup("tail")) +} + +func TestRunServicesLogs_RequiresServiceOrAll(t *testing.T) { + allLogs = false + err := runServicesLogs(newServicesLogsCmd(), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "specify a service") +} + +func TestRunServicesUp(t *testing.T) { + t.Run("starts requested services", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{} + injectDeps(t, mgr, nil) + + err := runServicesUp(newServicesUpCmd(), []string{"postgresql", "kafka"}) + + require.NoError(t, err) + assert.Equal(t, []string{"postgresql", "kafka"}, mgr.startedSvc) + }) + + t.Run("fails when daemon is unavailable", func(t *testing.T) { + cfgFile = nil + injectDeps(t, &fakeManager{}, errors.New("no daemon")) + + err := runServicesUp(newServicesUpCmd(), []string{"postgresql"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Docker daemon not available") + }) + + t.Run("errors when no services configured", func(t *testing.T) { + cfgFile = nil + injectDeps(t, &fakeManager{}, nil) + + // Default config has no mocks, so an argless up has nothing to start. + err := runServicesUp(newServicesUpCmd(), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no services") + }) + + t.Run("propagates start failure", func(t *testing.T) { + cfgFile = nil + injectDeps(t, &fakeManager{startErr: errors.New("boom")}, nil) + + err := runServicesUp(newServicesUpCmd(), []string{"postgresql"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to start postgresql") + }) +} + +func TestRunServicesDown(t *testing.T) { + t.Run("stops explicit services", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{} + injectDeps(t, mgr, nil) + + err := runServicesDown(newServicesDownCmd(), []string{"postgresql"}) + + require.NoError(t, err) + assert.Equal(t, []string{"postgresql"}, mgr.stoppedSvc) + }) + + t.Run("stops running services when none specified", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{running: []string{"kafka"}} + injectDeps(t, mgr, nil) + + err := runServicesDown(newServicesDownCmd(), nil) + + require.NoError(t, err) + assert.Equal(t, []string{"kafka"}, mgr.stoppedSvc) + }) + + t.Run("no-op when nothing is running", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{running: nil} + injectDeps(t, mgr, nil) + + err := runServicesDown(newServicesDownCmd(), nil) + + require.NoError(t, err) + assert.Empty(t, mgr.stoppedSvc) + }) + + t.Run("continues past a stop failure", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{stopErr: errors.New("boom")} + injectDeps(t, mgr, nil) + + err := runServicesDown(newServicesDownCmd(), []string{"postgresql", "kafka"}) + + require.NoError(t, err) + assert.Equal(t, []string{"postgresql", "kafka"}, mgr.stoppedSvc) + }) +} + +func TestRunServicesStatus(t *testing.T) { + t.Run("renders statuses", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{statuses: []*mock.ServiceStatus{ + {Name: "postgresql", Status: "running", Port: 5432, Uptime: time.Minute}, + {Name: "kafka", Status: "stopped"}, + {Name: "gcs", Status: "error"}, + }} + injectDeps(t, mgr, nil) + + err := runServicesStatus(newServicesStatusCmd(), nil) + require.NoError(t, err) + }) + + t.Run("handles no services", func(t *testing.T) { + cfgFile = nil + injectDeps(t, &fakeManager{}, nil) + + err := runServicesStatus(newServicesStatusCmd(), nil) + require.NoError(t, err) + }) +} + +func TestRunServicesLogs(t *testing.T) { + t.Run("prints logs for a service", func(t *testing.T) { + cfgFile = nil + allLogs = false + mgr := &fakeManager{logs: []string{"line1", "line2"}} + injectDeps(t, mgr, nil) + + err := runServicesLogs(newServicesLogsCmd(), []string{"postgresql"}) + require.NoError(t, err) + }) + + t.Run("with --all uses running services", func(t *testing.T) { + cfgFile = nil + mgr := &fakeManager{running: []string{"postgresql"}, logs: []string{"line1"}} + injectDeps(t, mgr, nil) + + // newServicesLogsCmd resets allLogs to its flag default, so set it after. + cmd := newServicesLogsCmd() + allLogs = true + t.Cleanup(func() { allLogs = false }) + + err := runServicesLogs(cmd, nil) + require.NoError(t, err) + }) + + t.Run("errors when --all but nothing running", func(t *testing.T) { + cfgFile = nil + injectDeps(t, &fakeManager{running: nil}, nil) + + cmd := newServicesLogsCmd() + allLogs = true + t.Cleanup(func() { allLogs = false }) + + err := runServicesLogs(cmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no running services") + }) + + t.Run("continues past a logs failure", func(t *testing.T) { + cfgFile = nil + allLogs = false + mgr := &fakeManager{logsErr: errors.New("boom")} + injectDeps(t, mgr, nil) + + err := runServicesLogs(newServicesLogsCmd(), []string{"postgresql"}) + require.NoError(t, err) + }) +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + in time.Duration + want string + }{ + {"seconds", 45 * time.Second, "45s"}, + {"minutes", 5 * time.Minute, "5m"}, + {"hours", 3 * time.Hour, "3h"}, + {"days", 48 * time.Hour, "2d"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatDuration(tt.in)) + }) + } +} + +func TestLoadConfigOrDefault(t *testing.T) { + t.Run("loads explicit config file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + content := []byte("version: v1\n" + + "app-technology: golang\n" + + "app-config:\n" + + " binary-name: test-app\n" + + " port: 8080\n" + + "test-launcher: test-launcher-back\n") + require.NoError(t, os.WriteFile(path, content, 0o600)) + + cfg, err := loadConfigOrDefault(path) + + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, "golang", cfg.AppTechnology) + }) + + t.Run("returns error for invalid explicit path", func(t *testing.T) { + _, err := loadConfigOrDefault(filepath.Join(t.TempDir(), "does-not-exist.yml")) + require.Error(t, err) + }) + + t.Run("falls back to defaults when no file given", func(t *testing.T) { + cfg, err := loadConfigOrDefault("") + + require.NoError(t, err) + require.NotNil(t, cfg) + }) +} diff --git a/internal/cli/test/test.go b/internal/cli/test/test.go new file mode 100644 index 0000000..3030027 --- /dev/null +++ b/internal/cli/test/test.go @@ -0,0 +1,299 @@ +package test + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + coreApp "github.com/oswaldom-code/gtool/internal/core/app" + "github.com/oswaldom-code/gtool/internal/core/app/nativeapp" + coreConfig "github.com/oswaldom-code/gtool/internal/core/config" + "github.com/oswaldom-code/gtool/internal/core/mock" + "github.com/oswaldom-code/gtool/internal/core/mock/stablemocks" + "github.com/oswaldom-code/gtool/internal/core/orchestrator" + coreTest "github.com/oswaldom-code/gtool/internal/core/test" + "github.com/oswaldom-code/gtool/internal/core/test/stablekarate" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + pluginServices "github.com/oswaldom-code/gtool/internal/plugin/services" + "github.com/oswaldom-code/gtool/pkg/config" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +// cfgFile points at the root --config flag value; it is dereferenced at run +// time because the persistent flag is parsed after the command is constructed. +var cfgFile *string + +// pipeline is the orchestrator surface used by the command. *orchestrator.Orchestrator +// satisfies it; tests inject a fake. +type pipeline interface { + Run(ctx context.Context) (*orchestrator.Result, error) +} + +type pipelineDeps struct { + pipeline pipeline + close func() error +} + +type depsFactory func(cfg *config.Config, log *zap.Logger) (*pipelineDeps, error) + +var newPipeline depsFactory = defaultPipeline + +// defaultPipeline wires the real mock manager, app manager and (stub) test +// runner into an orchestrator. +func defaultPipeline(cfg *config.Config, log *zap.Logger) (*pipelineDeps, error) { + dockerClient, err := docker.NewClient(log) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + + registry := plugin.NewRegistry() + if err := pluginServices.RegisterAll(registry, dockerClient, log); err != nil { + _ = dockerClient.Close() + return nil, fmt.Errorf("failed to register plugins: %w", err) + } + + mockMgr := mock.NewManager(registry, log, cfg.Orchestration, dockerClient) + appMgr := coreApp.NewDockerManager(dockerClient, log) + tests := coreTest.NewKarateRunner(dockerClient, "", log) + + return &pipelineDeps{ + pipeline: orchestrator.NewOrchestrator(cfg, mockMgr, appMgr, tests, log), + close: dockerClient.Close, + }, nil +} + +// NewTestCmd builds the `test` command, which runs the full pipeline. +func NewTestCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "test", + Short: "Run the full component test pipeline", + Long: `Run the complete component test pipeline with a single command: + + 1. Start the configured mock services + 2. Start the application under test + 3. Run the tests + 4. Tear everything down + +Whatever is started is always cleaned up, including on Ctrl-C.`, + RunE: runTest, + } + + cfgFile = configFile + cmd.Flags().BoolVar(&stableMode, "stable", false, "reproduce legacy 'component t' using STABLE mocks + native app + Karate") + cmd.Flags().StringVar(&stableTags, "tags", "", "Karate tags filter (with --stable)") + cmd.Flags().StringVar(&stableBuildConfig, "build-config", "build-config.yml", "path to build-config.yml (with --stable)") + cmd.Flags().BoolVar(&stableNoOpen, "no-open", false, "do not open the HTML report when finished (with --stable)") + cmd.AddCommand(newKarateCmd()) + return cmd +} + +var ( + stableMode bool + stableTags string + stableBuildConfig string + stableNoOpen bool +) + +var ( + karateTags string + karateUrlsToBlock string + karateFeatures string + karateReports string + karateImage string + karateNoOpen bool +) + +// newKarateCmd builds `gtool test karate`, reproducing the legacy +// "component e" (exec-only-tests): it runs the Karate launcher against the +// already-running mocks and app and opens the HTML report. +func newKarateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "karate", + Short: "Run only the Karate component tests (mocks and app must already be running)", + Long: `Run the Karate backend test launcher against the already-running mocks and +application, mounting test/component/features and writing the HTML report to +test/component/reports. The report is opened in the browser when it finishes. + +Bring the environment up first with: + gtool services up --stable + gtool app start --native`, + RunE: runKarate, + } + cmd.Flags().StringVar(&karateTags, "tags", "", "Karate tags filter (TAGS)") + cmd.Flags().StringVar(&karateUrlsToBlock, "urls-to-block", "", "comma-separated URLs to block") + cmd.Flags().StringVar(&karateFeatures, "features", "", "features path (default test/component/features)") + cmd.Flags().StringVar(&karateReports, "reports", "", "reports path (default test/component/reports)") + cmd.Flags().StringVar(&karateImage, "image", "", "override the test launcher image") + cmd.Flags().BoolVar(&karateNoOpen, "no-open", false, "do not open the HTML report when finished") + return cmd +} + +func runKarate(_ *cobra.Command, _ []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + dockerClient, err := docker.NewClient(log.Logger) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer dockerClient.Close() + if err := dockerClient.Ping(ctx); err != nil { + return fmt.Errorf("Docker daemon not available: %w", err) + } + + runner := stablekarate.New(dockerClient, log.Logger) + result, err := runner.Run(ctx, stablekarate.Options{ + Image: karateImage, + Tags: karateTags, + UrlsToBlock: karateUrlsToBlock, + FeaturesPath: karateFeatures, + ReportsPath: karateReports, + Open: !karateNoOpen, + }) + if err != nil { + return err + } + if !result.Passed { + return fmt.Errorf("karate tests failed (exit code %d)", result.ExitCode) + } + fmt.Printf("✅ Karate tests passed in %s\n", result.Duration.Round(time.Second)) + return nil +} + +// configFilePath returns the current value of the --config flag, or "". +func configFilePath() string { + if cfgFile == nil { + return "" + } + return *cfgFile +} + +func runTest(_ *cobra.Command, _ []string) error { + log := logger.Default() + defer log.Sync() + + // Cancel the pipeline on Ctrl-C / SIGTERM; the orchestrator still tears down. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := loadConfigOrDefault(configFilePath()) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + if stableMode { + return runStableTest(ctx, log.Logger, cfg) + } + + deps, err := newPipeline(cfg, log.Logger) + if err != nil { + return err + } + defer deps.close() + + fmt.Println("🧪 Running component test pipeline...") + result, runErr := deps.pipeline.Run(ctx) + printResult(result) + + if runErr != nil { + return fmt.Errorf("pipeline failed: %w", runErr) + } + + if result != nil && result.Test != nil && result.Test.Failed > 0 { + return fmt.Errorf("tests failed: %d of %d failed", result.Test.Failed, result.Test.Total) + } + + fmt.Println("\n✅ Pipeline completed") + return nil +} + +// runStableTest reproduces the legacy "component t": prepare STABLE mocks, +// launch the native app, run Karate, then always tear app and mocks down +// (LIFO defers run even on test failure or Ctrl-C). +func runStableTest(ctx context.Context, log *zap.Logger, cfg *config.Config) error { + dockerClient, err := docker.NewClient(log) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer dockerClient.Close() + if err := dockerClient.Ping(ctx); err != nil { + return fmt.Errorf("Docker daemon not available: %w", err) + } + + mocks, err := stablemocks.New(dockerClient, log) + if err != nil { + return err + } + app, err := nativeapp.New(log, stableBuildConfig) + if err != nil { + return err + } + karate := stablekarate.New(dockerClient, log) + + fmt.Println("▶ Phase 1/3: starting STABLE mocks...") + if err := mocks.Up(ctx, nil, cfg); err != nil { + _ = mocks.Down(context.Background(), nil) + return fmt.Errorf("failed to start mocks: %w", err) + } + defer func() { + fmt.Println("🧹 Stopping mocks...") + _ = mocks.Down(context.Background(), nil) + }() + + fmt.Println("\n▶ Phase 2/3: starting native app...") + if err := app.Start(ctx); err != nil { + _ = app.Stop(context.Background()) + return fmt.Errorf("failed to start app: %w", err) + } + defer func() { + fmt.Println("🧹 Stopping app...") + _ = app.Stop(context.Background()) + }() + + fmt.Println("\n▶ Phase 3/3: running Karate tests...") + result, err := karate.Run(ctx, stablekarate.Options{ + Tags: stableTags, + Open: !stableNoOpen, + }) + if err != nil { + return fmt.Errorf("test run failed: %w", err) + } + if !result.Passed { + return fmt.Errorf("tests failed (exit code %d)", result.ExitCode) + } + + fmt.Printf("\n✅ Pipeline completed (Karate passed in %s)\n", result.Duration.Round(time.Second)) + return nil +} + +func printResult(result *orchestrator.Result) { + if result == nil { + return + } + fmt.Printf(" mocks started: %v\n", result.MocksStarted) + fmt.Printf(" app started: %v\n", result.AppStarted) + if result.Test != nil { + fmt.Printf(" tests: %d total, %d passed, %d failed\n", + result.Test.Total, result.Test.Passed, result.Test.Failed) + } +} + +func loadConfigOrDefault(cfgFile string) (*config.Config, error) { + if cfgFile != "" { + return coreConfig.LoadConfig(cfgFile) + } + + cfg, err := coreConfig.LoadConfig("") + if err != nil { + return config.DefaultConfig(), nil + } + return cfg, nil +} diff --git a/internal/cli/test/test_test.go b/internal/cli/test/test_test.go new file mode 100644 index 0000000..e2dc1ac --- /dev/null +++ b/internal/cli/test/test_test.go @@ -0,0 +1,86 @@ +package test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/oswaldom-code/gtool/internal/core/orchestrator" + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" +) + +type fakePipeline struct { + result *orchestrator.Result + err error + called bool +} + +func (f *fakePipeline) Run(_ context.Context) (*orchestrator.Result, error) { + f.called = true + return f.result, f.err +} + +func injectPipeline(t *testing.T, p pipeline) { + t.Helper() + orig := newPipeline + t.Cleanup(func() { + newPipeline = orig + cfgFile = nil + }) + newPipeline = func(_ *config.Config, _ *zap.Logger) (*pipelineDeps, error) { + return &pipelineDeps{pipeline: p, close: func() error { return nil }}, nil + } +} + +func TestNewTestCmd(t *testing.T) { + cmd := NewTestCmd(nil) + assert.Equal(t, "test", cmd.Name()) + assert.NotNil(t, cmd.RunE) +} + +func TestRunTest_Success(t *testing.T) { + cfgFile = nil + fp := &fakePipeline{result: &orchestrator.Result{ + MocksStarted: true, + AppStarted: true, + Test: &plugin.TestResult{Total: 3, Passed: 3}, + }} + injectPipeline(t, fp) + + require.NoError(t, runTest(nil, nil)) + assert.True(t, fp.called) +} + +func TestRunTest_PipelineFailure(t *testing.T) { + cfgFile = nil + injectPipeline(t, &fakePipeline{ + result: &orchestrator.Result{MocksStarted: true}, + err: errors.New("boom"), + }) + + err := runTest(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline failed") +} + +func TestRunTest_FactoryFailure(t *testing.T) { + cfgFile = nil + orig := newPipeline + t.Cleanup(func() { newPipeline = orig }) + newPipeline = func(_ *config.Config, _ *zap.Logger) (*pipelineDeps, error) { + return nil, errors.New("docker down") + } + + err := runTest(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "docker down") +} + +func TestPrintResult_NilSafe(t *testing.T) { + printResult(nil) // must not panic +} diff --git a/internal/cli/unit/unit.go b/internal/cli/unit/unit.go new file mode 100644 index 0000000..29051af --- /dev/null +++ b/internal/cli/unit/unit.go @@ -0,0 +1,274 @@ +// Package unit reproduces the behaviour of the legacy "go-tool u" command: +// it generates mocks declared in build-config.yml and runs the unit-test +// suite with Ginkgo, producing a coverage profile and a JUnit report. +package unit + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +const ( + defaultBuildConfig = "build-config.yml" + mocksFolder = "mocks" + coverFolder = "coverage" + + // Tool versions are pinned to match the legacy go-tool behaviour so the + // generated mocks and the Ginkgo CLI flags stay identical. + mockgenURL = "go.uber.org/mock/mockgen@v0.3.0" + ginkgoURL = "github.com/onsi/ginkgo/v2/ginkgo@v2.2.0" +) + +// libWildcards maps the placeholders used in build-config.yml mock sources to +// the go.mod module substring used to resolve their path in the module cache. +// is handled separately because it expands to the module cache root. +var libWildcards = map[string]string{ + "": "common-library-back", + "": "search-library", + "": "business-library", + "": "analytics-library", + "": "backoffice-library", +} + +// buildConfig is the minimal view of build-config.yml needed for unit tests. +type buildConfig struct { + Mocks []mockSpec `yaml:"mocks"` +} + +type mockSpec struct { + Source string `yaml:"source"` + Filename string `yaml:"filename"` +} + +// NewUnitCmd creates the `gtool unit` command. +func NewUnitCmd() *cobra.Command { + var buildConfigFile string + var skipMocks bool + + cmd := &cobra.Command{ + Use: "unit", + Aliases: []string{"u"}, + Short: "Run unit tests (mock generation + Ginkgo)", + Long: `Reproduces the legacy "go-tool u" workflow: + + 1. Generates the mocks declared in build-config.yml (mockgen). + 2. Runs the unit-test suite recursively with Ginkgo, generating a + coverage profile and a JUnit report under ./coverage.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runUnit(buildConfigFile, skipMocks) + }, + } + + cmd.Flags().StringVar(&buildConfigFile, "build-config", defaultBuildConfig, "path to build-config.yml") + cmd.Flags().BoolVar(&skipMocks, "skip-mocks", false, "skip mock generation and only run the tests") + + return cmd +} + +func runUnit(buildConfigFile string, skipMocks bool) error { + gobin, err := goBin() + if err != nil { + return err + } + + if !skipMocks { + cfg, err := loadBuildConfig(buildConfigFile) + if err != nil { + return err + } + if err := buildMocks(gobin, cfg.Mocks); err != nil { + return err + } + } + + return execUnitTests(gobin) +} + +func loadBuildConfig(path string) (*buildConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, gtErrors.New(gtErrors.ErrConfigNotFound, + fmt.Sprintf("%s not found (required to generate mocks)", path)) + } + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to read build config") + } + + cfg := &buildConfig{} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to parse build config") + } + return cfg, nil +} + +// buildMocks reproduces go-tool's build_mocks: it resolves library wildcards and +// runs mockgen for every declared interface. +func buildMocks(gobin string, mocks []mockSpec) error { + if len(mocks) == 0 { + return nil + } + + if err := os.MkdirAll(mocksFolder, 0o755); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to create mocks folder") + } + + if err := installTool(gobin, mockgenURL); err != nil { + return err + } + if err := runStreaming("", nil, "go", "mod", "download"); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "go mod download failed") + } + + gopath, err := goEnv("GOPATH") + if err != nil { + return err + } + modCache := filepath.Join(gopath, "pkg", "mod") + + mockgen := filepath.Join(gobin, "mockgen") + for _, m := range mocks { + source, err := resolveSource(m.Source, modCache) + if err != nil { + return err + } + + dest := filepath.Join(mocksFolder, m.Filename) + fmt.Printf("Generating mock %s\n", m.Filename) + if err := runStreaming("", nil, mockgen, + "-package", mocksFolder, + "-source", source, + "-destination", dest); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, + fmt.Sprintf("mockgen failed for %s", m.Source)) + } + } + + return nil +} + +// resolveSource expands library wildcards in a mock source path to an absolute +// path inside the Go module cache, mirroring go-tool's wildcard handling. +func resolveSource(source, modCache string) (string, error) { + if strings.Contains(source, "") { + source = strings.ReplaceAll(source, "", modCache) + } + + for wildcard, module := range libWildcards { + if !strings.Contains(source, wildcard) { + continue + } + modPath, err := moduleCachePath(module, modCache) + if err != nil { + return "", err + } + source = strings.ReplaceAll(source, wildcard, modPath) + } + + return source, nil +} + +// moduleCachePath finds the first go.mod require line containing the given +// module substring and returns its "@" path in the cache. +func moduleCachePath(moduleSubstr, modCache string) (string, error) { + data, err := os.ReadFile("go.mod") + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrConfigNotFound, "failed to read go.mod") + } + + for _, line := range strings.Split(string(data), "\n") { + if !strings.Contains(line, moduleSubstr) { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 || strings.HasPrefix(fields[0], "//") { + continue + } + return filepath.Join(modCache, fields[0]+"@"+fields[1]), nil + } + + return "", gtErrors.New(gtErrors.ErrConfigInvalid, + fmt.Sprintf("module %q not found in go.mod", moduleSubstr)) +} + +// execUnitTests reproduces go-tool's exec_unit_tests Ginkgo invocation. +func execUnitTests(gobin string) error { + if err := installTool(gobin, ginkgoURL); err != nil { + return err + } + if err := os.MkdirAll(coverFolder, 0o755); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to create coverage folder") + } + + ginkgo := filepath.Join(gobin, "ginkgo") + env := append(os.Environ(), "LOG_LEVEL=panic") + err := runStreaming("", env, ginkgo, + "-r", + "--randomize-suites", + "--fail-on-pending", + "--progress", + "--cover", + "--covermode=set", + "--coverprofile=coverage.out", + "--junit-report=report.xml", + "--output-dir="+coverFolder, + ) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrTestFailed, "unit tests failed") + } + + fmt.Printf("\n✅ Unit tests passed. Coverage and report under ./%s\n", coverFolder) + return nil +} + +// installTool runs `go install ` so the pinned tool version is available, +// matching go-tool. A failure is fatal because the exact version is required. +func installTool(gobin, url string) error { + if err := runStreaming("", nil, "go", "install", url); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, + fmt.Sprintf("failed to install %s", url)) + } + return nil +} + +// runStreaming runs a command wiring its stdio to the current process. +func runStreaming(dir string, env []string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} + +func goBin() (string, error) { + gobin, err := goEnv("GOBIN") + if err != nil { + return "", err + } + if gobin != "" { + return gobin, nil + } + gopath, err := goEnv("GOPATH") + if err != nil { + return "", err + } + return filepath.Join(gopath, "bin"), nil +} + +func goEnv(key string) (string, error) { + out, err := exec.Command("go", "env", key).Output() + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, + fmt.Sprintf("failed to read go env %s", key)) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/cli/unit/unit_test.go b/internal/cli/unit/unit_test.go new file mode 100644 index 0000000..98abcf1 --- /dev/null +++ b/internal/cli/unit/unit_test.go @@ -0,0 +1,119 @@ +package unit + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sampleGoMod = `module notification + +go 1.24.1 + +require ( + git.pro.sisdig.dgrp.io/es-dia-ecom/common-library-back v1.45.0 + github.com/onsi/ginkgo/v2 v2.26.0 +) +` + +func TestLoadBuildConfig(t *testing.T) { + dir := t.TempDir() + + valid := filepath.Join(dir, "build-config.yml") + require.NoError(t, os.WriteFile(valid, []byte(`mocks: + - source: internal/foo/foo_interface.go + filename: foo_interface.go +`), 0o644)) + + invalid := filepath.Join(dir, "invalid.yml") + require.NoError(t, os.WriteFile(invalid, []byte("mocks: [oops"), 0o644)) + + tests := []struct { + name string + path string + wantMocks int + wantErr bool + errContains string + }{ + {name: "valid", path: valid, wantMocks: 1}, + {name: "not found", path: filepath.Join(dir, "missing.yml"), wantErr: true, errContains: "not found"}, + {name: "invalid yaml", path: invalid, wantErr: true, errContains: "parse"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := loadBuildConfig(tt.path) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + assert.Len(t, cfg.Mocks, tt.wantMocks) + }) + } +} + +func TestResolveSource(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(sampleGoMod), 0o644)) + t.Chdir(dir) + + modCache := "/home/user/go/pkg/mod" + + tests := []struct { + name string + source string + want string + wantErr bool + errContains string + }{ + { + name: "common-lib wildcard", + source: "/messaging/publisher/publisher_interface.go", + want: filepath.Join(modCache, "git.pro.sisdig.dgrp.io/es-dia-ecom/common-library-back@v1.45.0", "messaging/publisher/publisher_interface.go"), + }, + { + name: "lib-root wildcard", + source: "/some/path.go", + want: filepath.Join(modCache, "some/path.go"), + }, + { + name: "plain path untouched", + source: "internal/service/foo_interface.go", + want: "internal/service/foo_interface.go", + }, + { + name: "unknown module", + source: "/foo.go", + wantErr: true, + errContains: "search-library", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveSource(tt.source, modCache) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestModuleCachePathNotFound(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(sampleGoMod), 0o644)) + t.Chdir(dir) + + _, err := moduleCachePath("nonexistent-library", "/cache") + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent-library") +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..fca2669 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,42 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + Version = "0.1.0" + GitCommit = "dev" + BuildDate = "unknown" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Long: `Display version, git commit, and build date information for gtool. + +Examples: + # Show basic version + gtool version + + # Show full version information including git commit and build date + gtool version --full`, + Run: func(cmd *cobra.Command, args []string) { + full, _ := cmd.Flags().GetBool("full") + + if full { + fmt.Printf("gtool version %s\n", Version) + fmt.Printf("Git commit: %s\n", GitCommit) + fmt.Printf("Build date: %s\n", BuildDate) + } else { + fmt.Printf("gtool version %s\n", Version) + } + }, +} + +func init() { + versionCmd.Flags().Bool("full", false, "Show full version information") + rootCmd.AddCommand(versionCmd) +} diff --git a/internal/core/app/docker_manager.go b/internal/core/app/docker_manager.go new file mode 100644 index 0000000..e7c7977 --- /dev/null +++ b/internal/core/app/docker_manager.go @@ -0,0 +1,202 @@ +package app + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const appContainerPrefix = "gtool-app" + +// appLabels identify the application container managed by gtool. They are +// distinct from the mock service labels so the two never collide. +func appLabels() map[string]string { + return map[string]string{ + "managed-by": "gtool", + "gtool-role": "app", + } +} + +// DockerClient is the subset of *docker.Client used by the DockerManager. +type DockerClient interface { + PullImage(ctx context.Context, image string) error + CreateContainer(ctx context.Context, config *docker.ContainerConfig) (string, error) + StartContainer(ctx context.Context, containerID string) error + StopContainer(ctx context.Context, containerID string, timeout *int) error + RemoveContainer(ctx context.Context, containerID string, force bool) error + ListContainersByLabels(ctx context.Context, labels map[string]string) ([]types.Container, error) + GetContainerLogs(ctx context.Context, containerID string, tail int) (string, error) +} + +// DockerManager runs the application under test as a Docker container. Like the +// mock services, the container is labelled so separate CLI invocations can find +// it again. +type DockerManager struct { + docker DockerClient + logger *zap.Logger +} + +// NewDockerManager creates a Docker-backed app manager. +func NewDockerManager(dockerClient DockerClient, logger *zap.Logger) *DockerManager { + if logger == nil { + logger = zap.NewNop() + } + return &DockerManager{docker: dockerClient, logger: logger} +} + +// AppStatus describes the current state of the application container. +type AppStatus struct { + Running bool + State string + Image string + Port int +} + +// Start launches the application container. It fails if an application is +// already running. +func (m *DockerManager) Start(ctx context.Context, cfg *plugin.AppConfig) error { + if cfg.DockerImage == "" { + return gtErrors.New(gtErrors.ErrInvalidArgument, "docker-image is required to start the application") + } + + running, err := m.docker.ListContainersByLabels(ctx, appLabels()) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check for running application") + } + if len(running) > 0 { + return gtErrors.New(gtErrors.ErrServiceFailed, "application is already running") + } + + m.logger.Info("pulling application image", zap.String("image", cfg.DockerImage)) + if err := m.docker.PullImage(ctx, cfg.DockerImage); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull application image") + } + + containerConfig := &docker.ContainerConfig{ + Image: cfg.DockerImage, + Name: fmt.Sprintf("%s-%d", appContainerPrefix, time.Now().Unix()), + Env: envSlice(cfg.Environment), + PortBindings: portBindings(cfg.Port), + Labels: appLabels(), + } + + m.logger.Info("creating application container", + zap.String("image", cfg.DockerImage), + zap.Int("port", cfg.Port)) + + containerID, err := m.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create application container") + } + + if err := m.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start application container") + } + + m.logger.Info("application started", zap.String("containerID", containerID)) + return nil +} + +// Stop stops and removes the application container. +func (m *DockerManager) Stop(ctx context.Context) error { + containers, err := m.docker.ListContainersByLabels(ctx, appLabels()) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list application containers") + } + if len(containers) == 0 { + return gtErrors.New(gtErrors.ErrServiceNotRunning, "no application is running") + } + + timeout := 10 + for _, c := range containers { + m.logger.Info("stopping application container", zap.String("containerID", c.ID)) + if err := m.docker.StopContainer(ctx, c.ID, &timeout); err != nil { + m.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", c.ID)) + } + if err := m.docker.RemoveContainer(ctx, c.ID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove application container") + } + } + return nil +} + +// Restart stops the running application (if any) and starts it again. +func (m *DockerManager) Restart(ctx context.Context, cfg *plugin.AppConfig) error { + if err := m.Stop(ctx); err != nil && !gtErrors.Is(err, gtErrors.ErrServiceNotRunning) { + return err + } + return m.Start(ctx, cfg) +} + +// Status reports the state of the application container. +func (m *DockerManager) Status(ctx context.Context) (*AppStatus, error) { + containers, err := m.docker.ListContainersByLabels(ctx, appLabels()) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list application containers") + } + if len(containers) == 0 { + return &AppStatus{Running: false, State: "not-found"}, nil + } + + c := containers[0] + status := &AppStatus{ + State: c.State, + Image: c.Image, + Running: c.State == "running", + } + // A container may expose unmapped ports (PublicPort 0) alongside the + // published one; report the first port actually bound to the host. + for _, p := range c.Ports { + if p.PublicPort > 0 { + status.Port = int(p.PublicPort) + break + } + } + return status, nil +} + +// Logs returns the last tail lines of the application container logs. +func (m *DockerManager) Logs(ctx context.Context, tail int) ([]string, error) { + containers, err := m.docker.ListContainersByLabels(ctx, appLabels()) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list application containers") + } + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no application is running") + } + + logs, err := m.docker.GetContainerLogs(ctx, containers[0].ID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get application logs") + } + return strings.Split(strings.TrimSpace(logs), "\n"), nil +} + +// envSlice converts an environment map to KEY=VALUE entries. +func envSlice(env map[string]string) []string { + if len(env) == 0 { + return nil + } + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, k+"="+v) + } + return out +} + +// portBindings maps the application port to the same host port, if set. +func portBindings(port int) map[string]string { + if port <= 0 { + return nil + } + p := strconv.Itoa(port) + return map[string]string{p: p} +} diff --git a/internal/core/app/docker_manager_test.go b/internal/core/app/docker_manager_test.go new file mode 100644 index 0000000..c22c4bc --- /dev/null +++ b/internal/core/app/docker_manager_test.go @@ -0,0 +1,221 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +// fakeDocker is a controllable DockerClient for the app manager tests. +type fakeDocker struct { + containers []types.Container + listErr error + pullErr error + createErr error + startErr error + removeErr error + logs string + logsErr error + + created *docker.ContainerConfig + pulled string + stopped []string + removed []string + startedID string +} + +func (d *fakeDocker) PullImage(_ context.Context, image string) error { + d.pulled = image + return d.pullErr +} + +func (d *fakeDocker) CreateContainer(_ context.Context, cfg *docker.ContainerConfig) (string, error) { + d.created = cfg + if d.createErr != nil { + return "", d.createErr + } + return "container-id", nil +} + +func (d *fakeDocker) StartContainer(_ context.Context, id string) error { + d.startedID = id + return d.startErr +} + +func (d *fakeDocker) StopContainer(_ context.Context, id string, _ *int) error { + d.stopped = append(d.stopped, id) + return nil +} + +func (d *fakeDocker) RemoveContainer(_ context.Context, id string, _ bool) error { + d.removed = append(d.removed, id) + return d.removeErr +} + +func (d *fakeDocker) ListContainersByLabels(_ context.Context, _ map[string]string) ([]types.Container, error) { + return d.containers, d.listErr +} + +func (d *fakeDocker) GetContainerLogs(_ context.Context, _ string, _ int) (string, error) { + return d.logs, d.logsErr +} + +func TestDockerManager_Start(t *testing.T) { + ctx := context.Background() + + t.Run("starts the application", func(t *testing.T) { + d := &fakeDocker{} + m := NewDockerManager(d, nil) + + err := m.Start(ctx, &plugin.AppConfig{ + DockerImage: "myapp:latest", + Port: 8080, + Environment: map[string]string{"FOO": "bar"}, + }) + + require.NoError(t, err) + assert.Equal(t, "myapp:latest", d.pulled) + assert.Equal(t, "container-id", d.startedID) + assert.Equal(t, "myapp:latest", d.created.Image) + assert.Equal(t, map[string]string{"8080": "8080"}, d.created.PortBindings) + assert.Contains(t, d.created.Env, "FOO=bar") + assert.Equal(t, "app", d.created.Labels["gtool-role"]) + }) + + t.Run("requires docker image", func(t *testing.T) { + m := NewDockerManager(&fakeDocker{}, nil) + + err := m.Start(ctx, &plugin.AppConfig{}) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrInvalidArgument)) + }) + + t.Run("fails when already running", func(t *testing.T) { + d := &fakeDocker{containers: []types.Container{{ID: "x", State: "running"}}} + m := NewDockerManager(d, nil) + + err := m.Start(ctx, &plugin.AppConfig{DockerImage: "myapp:latest"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "already running") + }) + + t.Run("wraps pull failure", func(t *testing.T) { + d := &fakeDocker{pullErr: errors.New("boom")} + m := NewDockerManager(d, nil) + + err := m.Start(ctx, &plugin.AppConfig{DockerImage: "myapp:latest"}) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrDockerFailed)) + }) +} + +func TestDockerManager_Stop(t *testing.T) { + ctx := context.Background() + + t.Run("stops and removes the container", func(t *testing.T) { + d := &fakeDocker{containers: []types.Container{{ID: "c1", State: "running"}}} + m := NewDockerManager(d, nil) + + require.NoError(t, m.Stop(ctx)) + assert.Equal(t, []string{"c1"}, d.stopped) + assert.Equal(t, []string{"c1"}, d.removed) + }) + + t.Run("errors when nothing running", func(t *testing.T) { + m := NewDockerManager(&fakeDocker{}, nil) + + err := m.Stop(ctx) + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceNotRunning)) + }) +} + +func TestDockerManager_Restart(t *testing.T) { + ctx := context.Background() + + t.Run("starts when nothing was running", func(t *testing.T) { + d := &fakeDocker{} // no containers -> Stop returns not-running, restart ignores it + m := NewDockerManager(d, nil) + + err := m.Restart(ctx, &plugin.AppConfig{DockerImage: "myapp:latest"}) + require.NoError(t, err) + assert.Equal(t, "myapp:latest", d.pulled) + }) +} + +func TestDockerManager_Status(t *testing.T) { + ctx := context.Background() + + t.Run("running with port", func(t *testing.T) { + d := &fakeDocker{containers: []types.Container{{ + State: "running", + Image: "myapp:latest", + Ports: []types.Port{{PublicPort: 8080}}, + }}} + m := NewDockerManager(d, nil) + + st, err := m.Status(ctx) + require.NoError(t, err) + assert.True(t, st.Running) + assert.Equal(t, "running", st.State) + assert.Equal(t, "myapp:latest", st.Image) + assert.Equal(t, 8080, st.Port) + }) + + t.Run("skips unmapped exposed ports", func(t *testing.T) { + d := &fakeDocker{containers: []types.Container{{ + State: "running", + Image: "nginx:alpine", + Ports: []types.Port{{PrivatePort: 80}, {PublicPort: 8080}}, + }}} + m := NewDockerManager(d, nil) + + st, err := m.Status(ctx) + require.NoError(t, err) + assert.Equal(t, 8080, st.Port, "should report the host-published port, not the exposed one") + }) + + t.Run("not found", func(t *testing.T) { + m := NewDockerManager(&fakeDocker{}, nil) + + st, err := m.Status(ctx) + require.NoError(t, err) + assert.False(t, st.Running) + assert.Equal(t, "not-found", st.State) + }) +} + +func TestDockerManager_Logs(t *testing.T) { + ctx := context.Background() + + t.Run("returns log lines", func(t *testing.T) { + d := &fakeDocker{ + containers: []types.Container{{ID: "c1"}}, + logs: "line1\nline2\n", + } + m := NewDockerManager(d, nil) + + logs, err := m.Logs(ctx, 100) + require.NoError(t, err) + assert.Equal(t, []string{"line1", "line2"}, logs) + }) + + t.Run("errors when nothing running", func(t *testing.T) { + m := NewDockerManager(&fakeDocker{}, nil) + + _, err := m.Logs(ctx, 100) + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceNotRunning)) + }) +} diff --git a/internal/core/app/generic.go b/internal/core/app/generic.go new file mode 100644 index 0000000..2f68aaa --- /dev/null +++ b/internal/core/app/generic.go @@ -0,0 +1,32 @@ +package app + +import ( + "net" + "strconv" + + "github.com/oswaldom-code/gtool/internal/infra/process" + "go.uber.org/zap" +) + +// GenericLauncher runs an arbitrary local executable or script as the +// application under test, with a TCP readiness probe. +type GenericLauncher struct { + *processLauncher +} + +// NewGenericLauncher creates a generic launcher backed by the given process +// manager. +func NewGenericLauncher(procMgr *process.Manager, logger *zap.Logger) *GenericLauncher { + return &GenericLauncher{newProcessLauncher("generic", procMgr, logger, tcpProbe)} +} + +// tcpProbe reports whether a TCP connection to the port succeeds. +func tcpProbe(port int) bool { + addr := net.JoinHostPort("localhost", strconv.Itoa(port)) + conn, err := net.DialTimeout("tcp", addr, probeTimeout) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/internal/core/app/generic_test.go b/internal/core/app/generic_test.go new file mode 100644 index 0000000..2907541 --- /dev/null +++ b/internal/core/app/generic_test.go @@ -0,0 +1,151 @@ +package app + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/process" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +// writeScript creates an executable shell script running body and returns its path. +func writeScript(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "app.sh") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755)) + return path +} + +func newLauncher() *GenericLauncher { + return NewGenericLauncher(process.NewManager(nil), nil) +} + +func TestGenericLauncher_Technology(t *testing.T) { + assert.Equal(t, "generic", newLauncher().Technology()) +} + +func TestGenericLauncher_Launch_NoBinary(t *testing.T) { + g := newLauncher() + + err := g.Launch(context.Background(), &plugin.AppConfig{}) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrInvalidArgument)) +} + +func TestGenericLauncher_Launch_InvalidCommand(t *testing.T) { + g := newLauncher() + + err := g.Launch(context.Background(), &plugin.AppConfig{ + BinaryName: "this-binary-does-not-exist-gtool", + }) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrProcessFailed)) +} + +func TestGenericLauncher_LaunchAndGetPID(t *testing.T) { + ctx := context.Background() + g := newLauncher() + + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{BinaryPath: writeScript(t, "sleep 30")})) + + pid, err := g.GetPID() + require.NoError(t, err) + assert.Greater(t, pid, 0) + + require.NoError(t, g.Stop(ctx)) + + _, err = g.GetPID() + assert.Error(t, err, "GetPID should fail after stop") +} + +func TestGenericLauncher_IsReady_NotLaunched(t *testing.T) { + g := newLauncher() + + ready, err := g.IsReady(context.Background()) + + assert.False(t, ready) + require.Error(t, err) + assert.Contains(t, err.Error(), "not launched") +} + +func TestGenericLauncher_IsReady_NoPort(t *testing.T) { + ctx := context.Background() + g := newLauncher() + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{BinaryPath: writeScript(t, "sleep 30")})) + defer g.Stop(ctx) + + ready, err := g.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "a running process with no port should be ready") +} + +func TestGenericLauncher_IsReady_ProcessExited(t *testing.T) { + ctx := context.Background() + g := newLauncher() + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{BinaryName: "true"})) + + require.Eventually(t, func() bool { + ready, err := g.IsReady(ctx) + return err == nil && !ready + }, 2*time.Second, 10*time.Millisecond, "an exited process should report not ready") +} + +func TestGenericLauncher_IsReady_PortProbe(t *testing.T) { + ctx := context.Background() + + ln, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + + g := newLauncher() + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{ + BinaryPath: writeScript(t, "sleep 30"), + Port: port, + })) + defer g.Stop(ctx) + + ready, err := g.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "open port should report ready") + + require.NoError(t, ln.Close()) + + ready, err = g.IsReady(ctx) + require.NoError(t, err) + assert.False(t, ready, "closed port should report not ready") +} + +func TestGenericLauncher_Stop_NotLaunched(t *testing.T) { + assert.NoError(t, newLauncher().Stop(context.Background())) +} + +func TestGenericLauncher_Restart(t *testing.T) { + ctx := context.Background() + g := newLauncher() + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{BinaryPath: writeScript(t, "sleep 30")})) + + pid1, err := g.GetPID() + require.NoError(t, err) + + require.NoError(t, g.Restart(ctx)) + defer g.Stop(ctx) + + pid2, err := g.GetPID() + require.NoError(t, err) + assert.NotEqual(t, pid1, pid2, "restart should produce a new process") +} + +func TestGenericLauncher_Restart_NotLaunched(t *testing.T) { + err := newLauncher().Restart(context.Background()) + require.Error(t, err) +} diff --git a/internal/core/app/golang.go b/internal/core/app/golang.go new file mode 100644 index 0000000..554b6b4 --- /dev/null +++ b/internal/core/app/golang.go @@ -0,0 +1,35 @@ +package app + +import ( + "fmt" + "net/http" + + "github.com/oswaldom-code/gtool/internal/infra/process" + "go.uber.org/zap" +) + +// golangProbeClient is used by the HTTP readiness probe. +var golangProbeClient = &http.Client{Timeout: probeTimeout} + +// GolangLauncher runs a compiled Go application as the application under test, +// with an HTTP readiness probe (any HTTP response means the server is up). +type GolangLauncher struct { + *processLauncher +} + +// NewGolangLauncher creates a Go launcher backed by the given process manager. +func NewGolangLauncher(procMgr *process.Manager, logger *zap.Logger) *GolangLauncher { + return &GolangLauncher{newProcessLauncher("golang", procMgr, logger, httpProbe)} +} + +// httpProbe reports whether the port answers an HTTP request. Any response +// (including 4xx/5xx) means the server is accepting requests; only a transport +// error counts as not ready. +func httpProbe(port int) bool { + resp, err := golangProbeClient.Get(fmt.Sprintf("http://localhost:%d/", port)) + if err != nil { + return false + } + _ = resp.Body.Close() + return true +} diff --git a/internal/core/app/golang_test.go b/internal/core/app/golang_test.go new file mode 100644 index 0000000..671dc4f --- /dev/null +++ b/internal/core/app/golang_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/process" + "github.com/oswaldom-code/gtool/internal/plugin" +) + +func newGolang() *GolangLauncher { + return NewGolangLauncher(process.NewManager(nil), nil) +} + +func TestGolangLauncher_Technology(t *testing.T) { + assert.Equal(t, "golang", newGolang().Technology()) +} + +func TestGolangLauncher_LaunchAndStop(t *testing.T) { + ctx := context.Background() + g := newGolang() + + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{BinaryPath: writeScript(t, "sleep 30")})) + + pid, err := g.GetPID() + require.NoError(t, err) + assert.Greater(t, pid, 0) + + require.NoError(t, g.Stop(ctx)) +} + +func TestGolangLauncher_IsReady_HTTPProbe(t *testing.T) { + ctx := context.Background() + + // Server replies 404, which still proves the HTTP server is up. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + port := srv.Listener.Addr().(*net.TCPAddr).Port + + g := newGolang() + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{ + BinaryPath: writeScript(t, "sleep 30"), + Port: port, + })) + defer g.Stop(ctx) + + ready, err := g.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "any HTTP response means the server is up") + + srv.Close() + + ready, err = g.IsReady(ctx) + require.NoError(t, err) + assert.False(t, ready, "no server should report not ready") +} + +func TestGolangLauncher_IsReady_NoPort(t *testing.T) { + ctx := context.Background() + g := newGolang() + require.NoError(t, g.Launch(ctx, &plugin.AppConfig{BinaryPath: writeScript(t, "sleep 30")})) + defer g.Stop(ctx) + + ready, err := g.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready) +} + +func TestHTTPProbe_NoServer(t *testing.T) { + // A port with nothing listening must probe as not ready. + ln, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + require.NoError(t, ln.Close()) + + assert.False(t, httpProbe(port)) +} diff --git a/internal/core/app/launcher.go b/internal/core/app/launcher.go new file mode 100644 index 0000000..c2f5b62 --- /dev/null +++ b/internal/core/app/launcher.go @@ -0,0 +1,37 @@ +package app + +import ( + "context" + + "github.com/oswaldom-code/gtool/internal/plugin" +) + +// Manager manages application lifecycle +type Manager struct { + launcher plugin.AppLauncher +} + +// NewManager creates a new app manager +func NewManager(launcher plugin.AppLauncher) *Manager { + return &Manager{ + launcher: launcher, + } +} + +// Start starts the application +func (m *Manager) Start(ctx context.Context, config *plugin.AppConfig) error { + // TODO: Implement in Phase 3 + return nil +} + +// Stop stops the application +func (m *Manager) Stop(ctx context.Context) error { + // TODO: Implement in Phase 3 + return nil +} + +// Restart restarts the application +func (m *Manager) Restart(ctx context.Context) error { + // TODO: Implement in Phase 3 + return nil +} diff --git a/internal/core/app/launcher_base.go b/internal/core/app/launcher_base.go new file mode 100644 index 0000000..75aedad --- /dev/null +++ b/internal/core/app/launcher_base.go @@ -0,0 +1,142 @@ +package app + +import ( + "context" + "time" + + "github.com/oswaldom-code/gtool/internal/infra/process" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + stopTimeout = 10 * time.Second + probeTimeout = 2 * time.Second +) + +// readinessProbe reports whether a service listening on the given port is +// considered ready. Implementations differ per technology (TCP vs HTTP). +type readinessProbe func(port int) bool + +// processLauncher is the shared implementation for launchers that run the +// application as a local process. Concrete launchers embed it and supply a +// technology name and a readiness probe. +type processLauncher struct { + technology string + procMgr *process.Manager + logger *zap.Logger + probe readinessProbe + + config *plugin.AppConfig + pid int +} + +func newProcessLauncher(technology string, procMgr *process.Manager, logger *zap.Logger, probe readinessProbe) *processLauncher { + if logger == nil { + logger = zap.NewNop() + } + return &processLauncher{ + technology: technology, + procMgr: procMgr, + logger: logger, + probe: probe, + } +} + +// Technology returns the launcher identifier. +func (l *processLauncher) Technology() string { + return l.technology +} + +// Launch starts the application process. +func (l *processLauncher) Launch(ctx context.Context, config *plugin.AppConfig) error { + command := resolveCommand(config) + if command == "" { + return gtErrors.New(gtErrors.ErrInvalidArgument, + "launcher requires binary-path or binary-name") + } + + l.logger.Info("launching application", + zap.String("technology", l.technology), + zap.String("command", command), + zap.Int("port", config.Port)) + + proc, err := l.procMgr.Start(ctx, process.StartOptions{ + Command: command, + Env: config.Environment, + WorkDir: config.WorkDir, + }) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrProcessFailed, "failed to launch application") + } + + l.config = config + l.pid = proc.PID + + l.logger.Info("application launched", zap.Int("pid", l.pid)) + return nil +} + +// IsReady reports whether the application is up. With a configured port it runs +// the technology-specific probe; otherwise a running process is considered ready. +func (l *processLauncher) IsReady(_ context.Context) (bool, error) { + if l.pid == 0 { + return false, gtErrors.New(gtErrors.ErrProcessFailed, "application not launched") + } + + if !l.procMgr.IsRunning(l.pid) { + return false, nil + } + + if l.config.Port <= 0 { + return true, nil + } + + return l.probe(l.config.Port), nil +} + +// Stop terminates the application process gracefully. +func (l *processLauncher) Stop(_ context.Context) error { + if l.pid == 0 { + return nil + } + + l.logger.Info("stopping application", zap.Int("pid", l.pid)) + err := l.procMgr.Stop(l.pid, stopTimeout) + l.pid = 0 + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrProcessFailed, "failed to stop application") + } + return nil +} + +// Restart stops and relaunches the application with the original config. +func (l *processLauncher) Restart(ctx context.Context) error { + if l.config == nil { + return gtErrors.New(gtErrors.ErrProcessFailed, "application not launched") + } + + config := l.config + if err := l.Stop(ctx); err != nil { + return err + } + return l.Launch(ctx, config) +} + +// GetPID returns the PID of the running application. +func (l *processLauncher) GetPID() (int, error) { + if l.pid == 0 { + return 0, gtErrors.New(gtErrors.ErrProcessFailed, "application not running") + } + return l.pid, nil +} + +// resolveCommand picks the executable to run: an explicit binary path takes +// precedence, otherwise the binary name is resolved via PATH. +func resolveCommand(config *plugin.AppConfig) string { + if config.BinaryPath != "" { + return config.BinaryPath + } + return config.BinaryName +} diff --git a/internal/core/app/nativeapp/launcher.go b/internal/core/app/nativeapp/launcher.go new file mode 100644 index 0000000..68af05b --- /dev/null +++ b/internal/core/app/nativeapp/launcher.go @@ -0,0 +1,258 @@ +// Package nativeapp reproduces the legacy DIA component tool's golang app +// launcher (app-launchers/golang.bash, no docker image): it starts the project +// binaries built into $GOPATH/bin as detached native processes, each on an +// incrementing port with the emulator host env vars, and stops them by name. +package nativeapp + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "go.uber.org/zap" + "gopkg.in/yaml.v3" + + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +const ( + basePort = 8080 + baseExtraPort = 7080 + + // Emulator hosts match the legacy launcher's exported PUBSUB/STORAGE values + // (the STABLE pubsub/gcs mocks publish on these host ports). + pubsubEmulatorHost = "127.0.0.1:9085" + storageEmulatorHost = "127.0.0.1:9086" + + // readyTimeout bounds how long we wait for the primary service port to + // accept connections before proceeding anyway (replaces a blind "sleep 5"). + readyTimeout = 30 * time.Second + readyPoll = 300 * time.Millisecond + + defaultBuildConfig = "build-config.yml" +) + +// runner abstracts process start/stop so tests don't spawn real processes. +type runner interface { + start(binaryPath string, args []string, env []string) error + stopByName(name string) error +} + +// Launcher starts and stops the native app binaries. +type Launcher struct { + logger *zap.Logger + gobin string + workDir string + buildConfigPath string + run runner + readyTimeout time.Duration + dial func(addr string) error +} + +// New builds a Launcher resolving $GOPATH/bin and the working directory. +func New(logger *zap.Logger, buildConfigPath string) (*Launcher, error) { + if logger == nil { + logger = zap.NewNop() + } + if buildConfigPath == "" { + buildConfigPath = defaultBuildConfig + } + gobin, err := goBin() + if err != nil { + return nil, err + } + wd, err := os.Getwd() + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to resolve working directory") + } + return &Launcher{ + logger: logger, + gobin: gobin, + workDir: wd, + buildConfigPath: buildConfigPath, + run: &osRunner{}, + readyTimeout: readyTimeout, + dial: tcpDial, + }, nil +} + +// Start launches every configured binary as a detached process. +func (l *Launcher) Start(ctx context.Context) error { + binaries, err := l.binaries() + if err != nil { + return err + } + + for i, name := range binaries { + binPath := filepath.Join(l.gobin, name) + if _, statErr := os.Stat(binPath); statErr != nil { + return gtErrors.New(gtErrors.ErrProcessFailed, + fmt.Sprintf("binary %s not found (build it first, e.g. go build -o %s)", binPath, binPath)) + } + + // Replace any previously running instance, like the legacy "pkill" step. + _ = l.run.stopByName(name) + + port := basePort + i + extraPort := baseExtraPort + i + env := append(os.Environ(), + fmt.Sprintf("CUSTOM_SERVER_ADDRESS=0.0.0.0:%d", extraPort), + "PUBSUB_EMULATOR_HOST="+pubsubEmulatorHost, + "STORAGE_EMULATOR_HOST="+storageEmulatorHost, + ) + + fmt.Printf("🚀 Starting %s on port %d...\n", name, port) + if err := l.run.start(binPath, []string{"--port", fmt.Sprintf("%d", port)}, env); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrProcessFailed, + fmt.Sprintf("failed to start %s", name)) + } + l.logger.Info("started native app binary", + zap.String("binary", name), zap.Int("port", port), zap.Int("extra-port", extraPort)) + } + + return l.waitForApp(ctx) +} + +// waitForApp polls the primary service port (the first binary's, 8080) until it +// accepts connections, replacing the legacy blind "sleep 5". If it never comes +// up within readyTimeout it warns and proceeds (best-effort: not every app +// binds that port). +func (l *Launcher) waitForApp(ctx context.Context) error { + if l.readyTimeout <= 0 || l.dial == nil { + return nil + } + + addr := fmt.Sprintf("127.0.0.1:%d", basePort) + fmt.Printf("⏳ Waiting for app on %s...\n", addr) + + attempts := int(l.readyTimeout / readyPoll) + if attempts < 1 { + attempts = 1 + } + for i := 0; i < attempts; i++ { + if l.dial(addr) == nil { + fmt.Printf("✅ App ready on %s\n", addr) + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(readyPoll): + } + } + + l.logger.Warn("app did not become ready in time; continuing", + zap.String("addr", addr), zap.Duration("timeout", l.readyTimeout)) + return nil +} + +func tcpDial(addr string) error { + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + return err + } + return conn.Close() +} + +// Stop terminates every configured binary by name. +func (l *Launcher) Stop(ctx context.Context) error { + binaries, err := l.binaries() + if err != nil { + return err + } + for _, name := range binaries { + if err := l.run.stopByName(name); err != nil { + l.logger.Warn("failed to stop binary", zap.String("binary", name), zap.Error(err)) + continue + } + fmt.Printf("🛑 %s stopped\n", name) + } + return nil +} + +// binaries returns the "-" binary names, where is the working +// directory basename, mirroring go-tool's list-binaries. +func (l *Launcher) binaries() ([]string, error) { + data, err := os.ReadFile(l.buildConfigPath) + if err != nil { + if os.IsNotExist(err) { + return nil, gtErrors.New(gtErrors.ErrConfigNotFound, + fmt.Sprintf("%s not found (required to list app binaries)", l.buildConfigPath)) + } + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to read build config") + } + + var cfg struct { + Build struct { + Binaries []struct { + Name string `yaml:"name"` + } `yaml:"binaries"` + } `yaml:"build"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to parse build config") + } + if len(cfg.Build.Binaries) == 0 { + return nil, gtErrors.New(gtErrors.ErrConfigInvalid, "no binaries defined in build config") + } + + appName := filepath.Base(l.workDir) + names := make([]string, 0, len(cfg.Build.Binaries)) + for _, b := range cfg.Build.Binaries { + if b.Name == "" { + continue + } + names = append(names, appName+"-"+b.Name) + } + return names, nil +} + +// osRunner runs real OS processes. +type osRunner struct{} + +func (o *osRunner) start(binaryPath string, args, env []string) error { + cmd := exec.Command(binaryPath, args...) + cmd.Env = env + // Detach into its own process group so it survives the gtool process exit, + // matching the legacy launcher's backgrounded "&" binaries. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + return cmd.Start() +} + +func (o *osRunner) stopByName(name string) error { + // Mirror the legacy "pkill -f $BINARY_NAME". Exit status 1 means no process + // matched, which is not an error for our purposes. + err := exec.Command("pkill", "-f", name).Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return nil + } + return err + } + return nil +} + +func goBin() (string, error) { + if gobin := strings.TrimSpace(goEnv("GOBIN")); gobin != "" { + return gobin, nil + } + gopath := strings.TrimSpace(goEnv("GOPATH")) + if gopath == "" { + return "", gtErrors.New(gtErrors.ErrInvalidArgument, "could not resolve GOPATH") + } + return filepath.Join(gopath, "bin"), nil +} + +func goEnv(key string) string { + out, err := exec.Command("go", "env", key).Output() + if err != nil { + return "" + } + return string(out) +} diff --git a/internal/core/app/nativeapp/launcher_test.go b/internal/core/app/nativeapp/launcher_test.go new file mode 100644 index 0000000..8e1e159 --- /dev/null +++ b/internal/core/app/nativeapp/launcher_test.go @@ -0,0 +1,142 @@ +package nativeapp + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type startCall struct { + path string + args []string + env []string +} + +type fakeRunner struct { + started []startCall + stopped []string +} + +func (f *fakeRunner) start(path string, args, env []string) error { + f.started = append(f.started, startCall{path: path, args: args, env: env}) + return nil +} + +func (f *fakeRunner) stopByName(name string) error { + f.stopped = append(f.stopped, name) + return nil +} + +const buildCfg = `build: + binaries: + - name: api + path: cmd/notification-server/main.go + - name: consumer + path: cmd/notification-consumer/main.go +` + +// newTestLauncher wires a launcher with a fake runner, a working dir whose +// basename is "notification" and a build-config on disk. +func newTestLauncher(t *testing.T) (*Launcher, *fakeRunner, string) { + t.Helper() + base := t.TempDir() + workDir := filepath.Join(base, "notification") + require.NoError(t, os.Mkdir(workDir, 0o755)) + + cfgPath := filepath.Join(workDir, "build-config.yml") + require.NoError(t, os.WriteFile(cfgPath, []byte(buildCfg), 0o644)) + + gobin := filepath.Join(base, "bin") + require.NoError(t, os.Mkdir(gobin, 0o755)) + + fr := &fakeRunner{} + l := &Launcher{ + logger: zap.NewNop(), + gobin: gobin, + workDir: workDir, + buildConfigPath: cfgPath, + run: fr, + readyTimeout: 0, // skip readiness polling in unit tests + } + return l, fr, gobin +} + +func TestBinaries(t *testing.T) { + l, _, _ := newTestLauncher(t) + names, err := l.binaries() + require.NoError(t, err) + assert.Equal(t, []string{"notification-api", "notification-consumer"}, names) +} + +func TestBinariesMissingConfig(t *testing.T) { + l := &Launcher{logger: zap.NewNop(), buildConfigPath: "/nope/build-config.yml"} + _, err := l.binaries() + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestStartLaunchesWithPortsAndEnv(t *testing.T) { + l, fr, gobin := newTestLauncher(t) + // The binaries must exist on disk for Start to launch them. + for _, n := range []string{"notification-api", "notification-consumer"} { + require.NoError(t, os.WriteFile(filepath.Join(gobin, n), []byte("#!/bin/sh\n"), 0o755)) + } + + require.NoError(t, l.Start(context.Background())) + + require.Len(t, fr.started, 2) + assert.Equal(t, filepath.Join(gobin, "notification-api"), fr.started[0].path) + assert.Equal(t, []string{"--port", "8080"}, fr.started[0].args) + assert.Equal(t, []string{"--port", "8081"}, fr.started[1].args) + assert.Contains(t, fr.started[0].env, "CUSTOM_SERVER_ADDRESS=0.0.0.0:7080") + assert.Contains(t, fr.started[1].env, "CUSTOM_SERVER_ADDRESS=0.0.0.0:7081") + assert.Contains(t, fr.started[0].env, "PUBSUB_EMULATOR_HOST=127.0.0.1:9085") + assert.Contains(t, fr.started[0].env, "STORAGE_EMULATOR_HOST=127.0.0.1:9086") + // Each binary is stopped before being (re)started. + assert.Equal(t, []string{"notification-api", "notification-consumer"}, fr.stopped) +} + +func TestStartFailsWhenBinaryMissing(t *testing.T) { + l, _, _ := newTestLauncher(t) + err := l.Start(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestStopStopsAllBinaries(t *testing.T) { + l, fr, _ := newTestLauncher(t) + require.NoError(t, l.Stop(context.Background())) + assert.Equal(t, []string{"notification-api", "notification-consumer"}, fr.stopped) +} + +func TestWaitForAppReadyWhenPortAccepts(t *testing.T) { + l, _, _ := newTestLauncher(t) + l.readyTimeout = 2 * time.Second + + calls := 0 + l.dial = func(addr string) error { + calls++ + if calls < 3 { // not ready on the first two polls + return assert.AnError + } + return nil // ready on the third + } + + require.NoError(t, l.waitForApp(context.Background())) + assert.GreaterOrEqual(t, calls, 3) +} + +func TestWaitForAppProceedsOnTimeout(t *testing.T) { + l, _, _ := newTestLauncher(t) + l.readyTimeout = 50 * time.Millisecond + l.dial = func(string) error { return assert.AnError } // never ready + + // Proceeds (no error) even though the app never came up. + require.NoError(t, l.waitForApp(context.Background())) +} diff --git a/internal/core/config/loader.go b/internal/core/config/loader.go new file mode 100644 index 0000000..be3b5d5 --- /dev/null +++ b/internal/core/config/loader.go @@ -0,0 +1,129 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "gopkg.in/yaml.v3" +) + +type Loader struct { + expandEnv bool +} + +func NewLoader() *Loader { + return &Loader{ + expandEnv: true, + } +} + +func (l *Loader) Load(path string) (*config.Config, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigNotFound, + fmt.Sprintf("configuration file not found: %s", path)) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, + "failed to read configuration file") + } + + if l.expandEnv { + data = []byte(l.expandEnvVars(string(data))) + } + + cfg := config.DefaultConfig() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, + "failed to parse YAML configuration") + } + + l.applyEnvOverrides(cfg) + + return cfg, nil +} + +func (l *Loader) LoadFromPath() (*config.Config, error) { + paths := []string{ + "./component-config.yml", + "./component-config.yaml", + "./gtool-config.yml", + "./gtool-config.yaml", + } + + var lastErr error + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + cfg, err := l.Load(path) + if err != nil { + lastErr = err + continue + } + return cfg, nil + } + } + + if lastErr != nil { + return nil, lastErr + } + + return nil, gtErrors.New(gtErrors.ErrConfigNotFound, + "no configuration file found in current directory. Expected: component-config.yml or gtool-config.yml") +} + +func (l *Loader) LoadFromPathOrDefault() *config.Config { + cfg, err := l.LoadFromPath() + if err != nil { + return config.DefaultConfig() + } + return cfg +} + +func (l *Loader) expandEnvVars(content string) string { + re := regexp.MustCompile(`\$\{([^}]+)}|\$([A-Za-z_][A-Za-z0-9_]*)`) + + return re.ReplaceAllStringFunc(content, func(match string) string { + varName := strings.TrimPrefix(match, "$") + varName = strings.TrimPrefix(varName, "{") + varName = strings.TrimSuffix(varName, "}") + + if value := os.Getenv(varName); value != "" { + return value + } + + return match + }) +} + +func (l *Loader) applyEnvOverrides(cfg *config.Config) { + if logLevel := os.Getenv("GTOOL_LOG_LEVEL"); logLevel != "" { + cfg.Observability.LogLevel = logLevel + } + + if parallelMocks := os.Getenv("GTOOL_PARALLEL_MOCKS"); parallelMocks != "" { + cfg.Orchestration.ParallelMocks = parallelMocks == "true" + } + + if dockerImage := os.Getenv("GTOOL_DOCKER_IMAGE"); dockerImage != "" { + cfg.AppConfig.DockerImage = dockerImage + } +} + +func (l *Loader) SetExpandEnv(expand bool) { + l.expandEnv = expand +} + +func LoadConfig(path string) (*config.Config, error) { + loader := NewLoader() + + if path == "" { + return loader.LoadFromPath() + } + + return loader.Load(path) +} diff --git a/internal/core/config/loader_test.go b/internal/core/config/loader_test.go new file mode 100644 index 0000000..c2563ae --- /dev/null +++ b/internal/core/config/loader_test.go @@ -0,0 +1,480 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/oswaldom-code/gtool/internal/core/config" + pkgConfig "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +const ( + fixturesBasePath = "../../../test/fixtures/config" + minimalConfigPath = fixturesBasePath + "/minimal.yml" + fullConfigPath = fixturesBasePath + "/full.yml" + defaultsOnlyPath = fixturesBasePath + "/defaults-only.yml" + invalidYAMLPath = fixturesBasePath + "/invalid-yaml.yml" + withEnvVarsPath = fixturesBasePath + "/with-env-vars.yml" + baseForOverridesPath = fixturesBasePath + "/base-for-overrides.yml" +) + +func TestLoader(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Loader Suite") +} + +var _ = Describe("Loader", func() { + var ( + loader *config.Loader + tempDir string + absMinimalConfig string + absFullConfig string + absInvalidYAML string + ) + + BeforeEach(func() { + loader = config.NewLoader() + var err error + tempDir, err = os.MkdirTemp("", "gtool-loader-test-*") + Expect(err).NotTo(HaveOccurred()) + + absMinimalConfig, err = filepath.Abs(minimalConfigPath) + Expect(err).NotTo(HaveOccurred()) + absFullConfig, err = filepath.Abs(fullConfigPath) + Expect(err).NotTo(HaveOccurred()) + absInvalidYAML, err = filepath.Abs(invalidYAMLPath) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + if tempDir != "" { + os.RemoveAll(tempDir) + } + }) + + Describe("NewLoader", func() { + It("should create a new loader instance", func() { + Expect(loader).NotTo(BeNil()) + }) + }) + + Describe("SetExpandEnv", func() { + It("should toggle environment variable expansion", func() { + loader.SetExpandEnv(false) + loader.SetExpandEnv(true) + }) + }) + + Describe("Load", func() { + Context("with valid configuration", func() { + It("should load minimal config successfully", func() { + cfg, err := loader.Load(minimalConfigPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + Expect(cfg.Version).To(Equal("v1")) + Expect(cfg.AppTechnology).To(Equal("golang")) + Expect(cfg.AppConfig.BinaryName).To(Equal("test-app")) + Expect(cfg.AppConfig.Port).To(Equal(8080)) + Expect(cfg.TestLauncher).To(Equal("test-launcher-back")) + }) + + It("should load full config with all fields", func() { + cfg, err := loader.Load(fullConfigPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Version).To(Equal("v1")) + Expect(cfg.AppTechnology).To(Equal("nodejs")) + Expect(cfg.AppConfig.BinaryName).To(Equal("my-service")) + Expect(cfg.AppConfig.BinaryPath).To(Equal("./dist")) + Expect(cfg.AppConfig.DockerImage).To(Equal("myapp:latest")) + Expect(cfg.AppConfig.Port).To(Equal(3000)) + Expect(cfg.AppConfig.Environment["NODE_ENV"]).To(Equal("test")) + Expect(cfg.AppConfig.Environment["LOG_LEVEL"]).To(Equal("debug")) + Expect(cfg.TestLauncher).To(Equal("test-launcher-front")) + Expect(cfg.TestConfig.Tags).To(Equal("@smoke")) + Expect(cfg.TestConfig.FeaturesPath).To(Equal("./features")) + Expect(cfg.TestConfig.ReportsPath).To(Equal("./reports")) + Expect(cfg.TestConfig.Parallel).To(BeTrue()) + Expect(cfg.ThirdParty.Mocks).To(HaveLen(2)) + Expect(cfg.ThirdParty.Mocks).To(ContainElements("couchbase", "postgresql")) + Expect(cfg.Orchestration.ParallelMocks).To(BeFalse()) + Expect(cfg.Orchestration.StartupTimeout).To(Equal(60 * time.Second)) + Expect(cfg.Orchestration.HealthCheckInterval).To(Equal(5 * time.Second)) + Expect(cfg.Orchestration.HealthCheckRetries).To(Equal(30)) + Expect(cfg.Orchestration.CleanupOnFailure).To(BeFalse()) + Expect(cfg.Orchestration.PreserveLogs).To(BeFalse()) + Expect(cfg.Observability.StructuredLogs).To(BeTrue()) + Expect(cfg.Observability.LogLevel).To(Equal("debug")) + Expect(cfg.Observability.MetricsEnabled).To(BeFalse()) + Expect(cfg.Observability.ReportFormat).To(Equal("json")) + }) + + It("should apply default values for missing fields", func() { + cfg, err := loader.Load(defaultsOnlyPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Orchestration.ParallelMocks).To(BeTrue()) + Expect(cfg.Orchestration.StartupTimeout).To(Equal(180 * time.Second)) + Expect(cfg.Orchestration.HealthCheckInterval).To(Equal(3 * time.Second)) + Expect(cfg.Orchestration.HealthCheckRetries).To(Equal(60)) + Expect(cfg.Orchestration.CleanupOnFailure).To(BeTrue()) + Expect(cfg.Orchestration.PreserveLogs).To(BeTrue()) + Expect(cfg.Observability.StructuredLogs).To(BeFalse()) + Expect(cfg.Observability.LogLevel).To(Equal("info")) + Expect(cfg.Observability.MetricsEnabled).To(BeTrue()) + Expect(cfg.Observability.ReportFormat).To(Equal("text")) + }) + + It("should handle empty file with defaults", func() { + emptyFile := filepath.Join(tempDir, "empty.yml") + err := os.WriteFile(emptyFile, []byte(""), 0644) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := loader.Load(emptyFile) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Version).To(Equal("v1")) + }) + }) + + Context("with invalid configuration", func() { + It("should return error for invalid YAML syntax", func() { + _, err := loader.Load(invalidYAMLPath) + + Expect(err).To(HaveOccurred()) + Expect(gtErrors.Is(err, gtErrors.ErrConfigInvalid)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("failed to parse YAML")) + }) + + It("should return error for non-existent file", func() { + _, err := loader.Load("/nonexistent/path/config.yml") + + Expect(err).To(HaveOccurred()) + Expect(gtErrors.Is(err, gtErrors.ErrConfigNotFound)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("configuration file not found")) + }) + }) + + Context("with unreadable file", func() { + It("should return error when file cannot be read", func() { + if os.Getuid() == 0 { + Skip("skipping test when running as root") + } + + unreadableFile := filepath.Join(tempDir, "unreadable.yml") + err := os.WriteFile(unreadableFile, []byte("version: v1"), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = os.Chmod(tempDir, 0000) + Expect(err).NotTo(HaveOccurred()) + defer os.Chmod(tempDir, 0755) + + _, err = loader.Load(unreadableFile) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("LoadFromPath", func() { + var originalDir string + + BeforeEach(func() { + var err error + originalDir, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + err = os.Chdir(tempDir) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + os.Chdir(originalDir) + }) + + DescribeTable("should load config from standard paths", + func(filename string) { + content, err := os.ReadFile(absMinimalConfig) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(filename, content, 0644) + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(filename) + + cfg, err := loader.LoadFromPath() + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + Expect(cfg.Version).To(Equal("v1")) + Expect(cfg.AppTechnology).To(Equal("golang")) + }, + Entry("component-config.yml", "component-config.yml"), + Entry("component-config.yaml", "component-config.yaml"), + Entry("gtool-config.yml", "gtool-config.yml"), + Entry("gtool-config.yaml", "gtool-config.yaml"), + ) + + It("should return error when no config file found", func() { + _, err := loader.LoadFromPath() + + Expect(err).To(HaveOccurred()) + Expect(gtErrors.Is(err, gtErrors.ErrConfigNotFound)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("no configuration file found")) + }) + + It("should return error when config file has invalid content", func() { + content, err := os.ReadFile(absInvalidYAML) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile("component-config.yml", content, 0644) + Expect(err).NotTo(HaveOccurred()) + + _, err = loader.LoadFromPath() + + Expect(err).To(HaveOccurred()) + Expect(gtErrors.Is(err, gtErrors.ErrConfigInvalid)).To(BeTrue()) + }) + + It("should respect priority order (component-config.yml first)", func() { + minimalContent, err := os.ReadFile(absMinimalConfig) + Expect(err).NotTo(HaveOccurred()) + fullContent, err := os.ReadFile(absFullConfig) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile("component-config.yml", minimalContent, 0644) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile("gtool-config.yml", fullContent, 0644) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := loader.LoadFromPath() + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.BinaryName).To(Equal("test-app")) + }) + }) + + Describe("LoadFromPathOrDefault", func() { + var originalDir string + + BeforeEach(func() { + var err error + originalDir, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + err = os.Chdir(tempDir) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + os.Chdir(originalDir) + }) + + It("should return loaded config when file exists", func() { + content, err := os.ReadFile(absFullConfig) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile("component-config.yml", content, 0644) + Expect(err).NotTo(HaveOccurred()) + + cfg := loader.LoadFromPathOrDefault() + + Expect(cfg.AppTechnology).To(Equal("nodejs")) + Expect(cfg.AppConfig.BinaryName).To(Equal("my-service")) + }) + + It("should return default config when no file exists", func() { + cfg := loader.LoadFromPathOrDefault() + + defaultCfg := pkgConfig.DefaultConfig() + Expect(cfg.Version).To(Equal(defaultCfg.Version)) + Expect(cfg.Orchestration.ParallelMocks).To(Equal(defaultCfg.Orchestration.ParallelMocks)) + }) + }) + + Describe("Environment Variable Expansion", func() { + AfterEach(func() { + os.Unsetenv("APP_NAME") + os.Unsetenv("DB_HOST") + os.Unsetenv("DB_PORT") + os.Unsetenv("TEST_VAR") + os.Unsetenv("MY_VAR_NAME") + os.Unsetenv("VAR123") + os.Unsetenv("EMPTY_VAR") + }) + + Context("with fixture file containing env vars", func() { + It("should expand ${VAR} syntax", func() { + os.Setenv("APP_NAME", "expanded-app") + os.Setenv("DB_HOST", "localhost") + os.Setenv("DB_PORT", "5432") + + cfg, err := loader.Load(withEnvVarsPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.BinaryName).To(Equal("expanded-app")) + Expect(cfg.AppConfig.Environment["DATABASE_URL"]).To(Equal("localhost:5432")) + }) + + It("should keep original when env var not set", func() { + cfg, err := loader.Load(withEnvVarsPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.BinaryName).To(Equal("${APP_NAME}")) + }) + }) + + Context("when expansion is disabled", func() { + It("should not expand variables", func() { + os.Setenv("APP_NAME", "expanded-value") + + loader.SetExpandEnv(false) + cfg, err := loader.Load(withEnvVarsPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.BinaryName).To(Equal("${APP_NAME}")) + }) + }) + + Context("with inline config for edge cases", func() { + DescribeTable("edge cases", + func(envVars map[string]string, binaryName, expected string) { + for k, v := range envVars { + os.Setenv(k, v) + } + + content := "version: v1\napp-technology: golang\napp-config:\n binary-name: " + binaryName + "\n port: 8080\ntest-launcher: test-launcher-back\n" + configFile := filepath.Join(tempDir, "edge-case.yml") + err := os.WriteFile(configFile, []byte(content), 0644) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := loader.Load(configFile) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.BinaryName).To(Equal(expected)) + }, + Entry("underscore in var name", + map[string]string{"MY_VAR_NAME": "value"}, + "${MY_VAR_NAME}", "value"), + Entry("numbers in var name", + map[string]string{"VAR123": "num-value"}, + "${VAR123}", "num-value"), + Entry("empty env var keeps original", + map[string]string{"EMPTY_VAR": ""}, + "${EMPTY_VAR}", "${EMPTY_VAR}"), + Entry("plain text without variables", + map[string]string{}, + "plain-text", "plain-text"), + ) + }) + }) + + Describe("Environment Overrides (GTOOL_* variables)", func() { + AfterEach(func() { + os.Unsetenv("GTOOL_LOG_LEVEL") + os.Unsetenv("GTOOL_PARALLEL_MOCKS") + os.Unsetenv("GTOOL_DOCKER_IMAGE") + }) + + It("should override log level", func() { + os.Setenv("GTOOL_LOG_LEVEL", "debug") + + cfg, err := loader.Load(baseForOverridesPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Observability.LogLevel).To(Equal("debug")) + }) + + It("should override parallel mocks to false", func() { + os.Setenv("GTOOL_PARALLEL_MOCKS", "false") + + cfg, err := loader.Load(baseForOverridesPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Orchestration.ParallelMocks).To(BeFalse()) + }) + + It("should override parallel mocks to true", func() { + os.Setenv("GTOOL_PARALLEL_MOCKS", "true") + + cfg, err := loader.Load(baseForOverridesPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Orchestration.ParallelMocks).To(BeTrue()) + }) + + It("should override docker image", func() { + os.Setenv("GTOOL_DOCKER_IMAGE", "custom-image:v2") + + cfg, err := loader.Load(baseForOverridesPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.DockerImage).To(Equal("custom-image:v2")) + }) + + It("should override multiple values", func() { + os.Setenv("GTOOL_LOG_LEVEL", "warn") + os.Setenv("GTOOL_PARALLEL_MOCKS", "false") + os.Setenv("GTOOL_DOCKER_IMAGE", "override:latest") + + cfg, err := loader.Load(baseForOverridesPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Observability.LogLevel).To(Equal("warn")) + Expect(cfg.Orchestration.ParallelMocks).To(BeFalse()) + Expect(cfg.AppConfig.DockerImage).To(Equal("override:latest")) + }) + + It("should not override when env vars not set", func() { + cfg, err := loader.Load(baseForOverridesPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Observability.LogLevel).To(Equal("info")) + Expect(cfg.Orchestration.ParallelMocks).To(BeTrue()) + Expect(cfg.AppConfig.DockerImage).To(BeEmpty()) + }) + }) + + Describe("LoadConfig helper function", func() { + It("should load from specified path", func() { + cfg, err := config.LoadConfig(minimalConfigPath) + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.BinaryName).To(Equal("test-app")) + Expect(cfg.AppConfig.Port).To(Equal(8080)) + }) + + It("should load from default path when empty string provided", func() { + originalDir, err := os.Getwd() + Expect(err).NotTo(HaveOccurred()) + defer os.Chdir(originalDir) + + // Solve absolute path for minimal config + absPath, err := filepath.Abs(minimalConfigPath) + Expect(err).NotTo(HaveOccurred()) + + err = os.Chdir(tempDir) + Expect(err).NotTo(HaveOccurred()) + + content, err := os.ReadFile(absPath) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile("component-config.yml", content, 0644) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := config.LoadConfig("") + + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.AppConfig.BinaryName).To(Equal("test-app")) + }) + + It("should return error when file not found", func() { + _, err := config.LoadConfig("/nonexistent/config.yml") + + Expect(err).To(HaveOccurred()) + Expect(gtErrors.Is(err, gtErrors.ErrConfigNotFound)).To(BeTrue()) + }) + }) +}) diff --git a/internal/core/config/validator.go b/internal/core/config/validator.go new file mode 100644 index 0000000..c7393ac --- /dev/null +++ b/internal/core/config/validator.go @@ -0,0 +1,187 @@ +package config + +import ( + "fmt" + "os" + + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +var ( + supportedVersions = []string{"v1"} + supportedTechnologies = []string{"golang", "nodejs", "generic"} + supportedLaunchers = []string{"test-launcher-back", "test-launcher-front"} + supportedMocks = []string{"mountebank", "couchbase", "postgresql", "kafka", "pubsub", "gcs", "redis", "mongodb", "mysql", "minio"} +) + +type Validator struct { + checkPaths bool +} + +func NewValidator() *Validator { + return &Validator{ + checkPaths: true, + } +} + +func (v *Validator) Validate(cfg *config.Config) error { + var errors []string + + if err := v.validateVersion(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateAppTechnology(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateTestLauncher(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateAppConfig(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateTestConfig(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateMockServices(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateOrchestration(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if len(errors) > 0 { + return gtErrors.New(gtErrors.ErrConfigInvalid, + fmt.Sprintf("configuration validation failed:\n - %s", joinErrors(errors))) + } + + return nil +} + +func (v *Validator) validateVersion(cfg *config.Config) error { + if cfg.Version == "" { + return fmt.Errorf("version is required") + } + + if !contains(supportedVersions, cfg.Version) { + return fmt.Errorf("unsupported version '%s'. Supported: %v", cfg.Version, supportedVersions) + } + + return nil +} + +func (v *Validator) validateAppTechnology(cfg *config.Config) error { + if cfg.AppTechnology == "" { + return fmt.Errorf("app-technology is required") + } + + if !contains(supportedTechnologies, cfg.AppTechnology) { + return fmt.Errorf("unsupported app-technology '%s'. Supported: %v", cfg.AppTechnology, supportedTechnologies) + } + + return nil +} + +func (v *Validator) validateTestLauncher(cfg *config.Config) error { + if cfg.TestLauncher == "" { + return fmt.Errorf("test-launcher is required") + } + + if !contains(supportedLaunchers, cfg.TestLauncher) { + return fmt.Errorf("unsupported test-launcher '%s'. Supported: %v", cfg.TestLauncher, supportedLaunchers) + } + + return nil +} + +func (v *Validator) validateAppConfig(cfg *config.Config) error { + appCfg := cfg.AppConfig + + if appCfg.DockerImage == "" { + if cfg.AppTechnology == "golang" { + if appCfg.BinaryName == "" { + return fmt.Errorf("app-config.binary-name is required for golang technology") + } + } + } + + if appCfg.Port < 0 || appCfg.Port > 65535 { + return fmt.Errorf("app-config.port must be between 0 and 65535") + } + + return nil +} + +func (v *Validator) validateTestConfig(cfg *config.Config) error { + testCfg := cfg.TestConfig + + if v.checkPaths { + if testCfg.FeaturesPath != "" { + if _, err := os.Stat(testCfg.FeaturesPath); os.IsNotExist(err) { + return fmt.Errorf("test-config.features-path does not exist: %s", testCfg.FeaturesPath) + } + } + } + + return nil +} + +func (v *Validator) validateMockServices(cfg *config.Config) error { + for _, mock := range cfg.ThirdParty.Mocks { + if !contains(supportedMocks, mock) { + return fmt.Errorf("unsupported mock service '%s'. Supported: %v", mock, supportedMocks) + } + } + + // Validate mock-specific configurations + + return nil +} + +func (v *Validator) validateOrchestration(cfg *config.Config) error { + orch := cfg.Orchestration + + if orch.StartupTimeout <= 0 { + return fmt.Errorf("orchestration.startup-timeout must be positive") + } + + if orch.HealthCheckInterval <= 0 { + return fmt.Errorf("orchestration.health-check-interval must be positive") + } + + if orch.HealthCheckRetries <= 0 { + return fmt.Errorf("orchestration.health-check-retries must be positive") + } + + return nil +} + +func (v *Validator) SetCheckPaths(check bool) { + v.checkPaths = check +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func joinErrors(errors []string) string { + result := "" + for i, err := range errors { + if i > 0 { + result += "\n - " + } + result += err + } + return result +} diff --git a/internal/core/mock/manager.go b/internal/core/mock/manager.go new file mode 100644 index 0000000..bd114d8 --- /dev/null +++ b/internal/core/mock/manager.go @@ -0,0 +1,467 @@ +package mock + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +// ServiceStatus represents the status of a service +type ServiceStatus struct { + Name string + Status string // "running", "stopped", "error", "starting" + Port int + Uptime time.Duration + Error string +} + +// Manager manages mock services lifecycle +type Manager struct { + registry *plugin.Registry + logger *zap.Logger + orchestration config.OrchestrationConfig + services map[string]*serviceState + docker DockerClient + mu sync.RWMutex +} + +// DockerClient interface for Docker operations +type DockerClient interface { + ListContainersByLabels(ctx context.Context, labels map[string]string) ([]types.Container, error) +} + +// serviceState tracks the state of a running service +type serviceState struct { + plugin plugin.ServicePlugin + startTime time.Time + stopped bool + error error +} + +// NewManager creates a new mock manager +func NewManager(registry *plugin.Registry, logger *zap.Logger, orchestration config.OrchestrationConfig, dockerClient DockerClient) *Manager { + if logger == nil { + logger = zap.NewNop() + } + + return &Manager{ + registry: registry, + logger: logger, + orchestration: orchestration, + services: make(map[string]*serviceState), + docker: dockerClient, + } +} + +// Start starts a specific service +func (m *Manager) Start(ctx context.Context, serviceName string, config map[string]interface{}) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if already running + if state, exists := m.services[serviceName]; exists && !state.stopped { + m.logger.Warn("service already running", zap.String("service", serviceName)) + return nil + } + + // Get plugin from registry + servicePlugin, err := m.registry.GetService(serviceName) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("service %s not found in registry", serviceName)) + } + + m.logger.Info("starting service", zap.String("service", serviceName)) + + // Launch the service + if err := servicePlugin.Launch(ctx, config); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to launch service %s", serviceName)) + } + + // Wait for service to be ready + m.logger.Info("waiting for service to be ready", zap.String("service", serviceName)) + + ready := false + for i := 0; i < m.orchestration.HealthCheckRetries; i++ { + isReady, err := servicePlugin.IsReady(ctx) + if err != nil { + m.logger.Debug("health check failed", + zap.String("service", serviceName), + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if isReady { + ready = true + break + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for service") + case <-time.After(m.orchestration.HealthCheckInterval): + // Continue to next attempt + } + } + + if !ready { + // Cleanup on failure if configured + if m.orchestration.CleanupOnFailure { + _ = servicePlugin.Stop(ctx) + } + return gtErrors.New(gtErrors.ErrServiceNotReady, + fmt.Sprintf("service %s did not become ready within timeout", serviceName)) + } + + // Store service state + m.services[serviceName] = &serviceState{ + plugin: servicePlugin, + startTime: time.Now(), + stopped: false, + } + + m.logger.Info("service started successfully", zap.String("service", serviceName)) + return nil +} + +// Stop stops a specific service +func (m *Manager) Stop(ctx context.Context, serviceName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // First, check if service is in memory + state, exists := m.services[serviceName] + + // If not in memory, try to find it in Docker and get plugin + if !exists { + m.logger.Info("service not in memory, checking Docker", zap.String("service", serviceName)) + + // Try to find containers for this service + if m.docker != nil { + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": serviceName, + }) + + if err != nil { + m.logger.Error("failed to list containers", zap.Error(err)) + } else if len(containers) == 0 { + return gtErrors.New(gtErrors.ErrServiceNotRunning, + fmt.Sprintf("service %s is not running", serviceName)) + } + } + + // Get plugin from registry to stop the service + servicePlugin, err := m.registry.GetService(serviceName) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("service %s not found in registry", serviceName)) + } + + m.logger.Info("stopping service via plugin", zap.String("service", serviceName)) + + if err := servicePlugin.Stop(ctx); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to stop service %s", serviceName)) + } + + m.logger.Info("service stopped", zap.String("service", serviceName)) + return nil + } + + // Service is in memory, stop normally + if state.stopped { + return nil + } + + m.logger.Info("stopping service", zap.String("service", serviceName)) + + if err := state.plugin.Stop(ctx); err != nil { + state.error = err + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to stop service %s", serviceName)) + } + + state.stopped = true + delete(m.services, serviceName) + + m.logger.Info("service stopped", zap.String("service", serviceName)) + return nil +} + +// StopAll stops all running services +func (m *Manager) StopAll(ctx context.Context) error { + m.mu.RLock() + serviceNames := make([]string, 0, len(m.services)) + for name := range m.services { + serviceNames = append(serviceNames, name) + } + m.mu.RUnlock() + + var errors []error + for _, name := range serviceNames { + if err := m.Stop(ctx, name); err != nil { + errors = append(errors, err) + } + } + + if len(errors) > 0 { + return fmt.Errorf("failed to stop some services: %v", errors) + } + + return nil +} + +// GetStatus returns the status of a specific service +func (m *Manager) GetStatus(ctx context.Context, serviceName string) (*ServiceStatus, error) { + m.mu.RLock() + state, exists := m.services[serviceName] + m.mu.RUnlock() + + // If not in memory, check Docker + if !exists { + if m.docker != nil { + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": serviceName, + }) + + if err != nil { + m.logger.Debug("failed to list containers for status", zap.Error(err)) + } else if len(containers) > 0 { + // Found container(s) in Docker + container := containers[0] // Use first container + status := &ServiceStatus{ + Name: serviceName, + Status: container.State, + } + + // Try to get port from container + if len(container.Ports) > 0 { + status.Port = int(container.Ports[0].PublicPort) + } + + // Calculate uptime + if container.State == "running" { + status.Status = "running" + // Note: container.Created is a Unix timestamp + status.Uptime = time.Since(time.Unix(container.Created, 0)) + } + + return status, nil + } + } + + // Not in memory and not in Docker + return &ServiceStatus{ + Name: serviceName, + Status: "stopped", + }, nil + } + + // Service is in memory + status := &ServiceStatus{ + Name: serviceName, + Uptime: time.Since(state.startTime), + } + + if state.stopped { + status.Status = "stopped" + return status, nil + } + + if state.error != nil { + status.Status = "error" + status.Error = state.error.Error() + return status, nil + } + + // Check if service is still ready + ready, err := state.plugin.IsReady(ctx) + if err != nil || !ready { + status.Status = "error" + if err != nil { + status.Error = err.Error() + } + return status, nil + } + + status.Status = "running" + + // Get connection info for port + if connInfo, err := state.plugin.GetConnectionInfo(); err == nil { + status.Port = connInfo.Port + } + + return status, nil +} + +// GetAllStatuses returns the status of all services +func (m *Manager) GetAllStatuses(ctx context.Context) []*ServiceStatus { + // Get running services from both memory and Docker + serviceNames := m.ListRunning() + + statuses := make([]*ServiceStatus, 0, len(serviceNames)) + for _, name := range serviceNames { + status, err := m.GetStatus(ctx, name) + if err != nil { + statuses = append(statuses, &ServiceStatus{ + Name: name, + Status: "error", + Error: err.Error(), + }) + } else { + statuses = append(statuses, status) + } + } + + return statuses +} + +// GetLogs retrieves logs from a service +func (m *Manager) GetLogs(ctx context.Context, serviceName string, opts *plugin.LogOptions) ([]string, error) { + m.mu.RLock() + state, exists := m.services[serviceName] + m.mu.RUnlock() + + // If in memory, use the plugin + if exists { + return state.plugin.GetLogs(ctx, opts) + } + + // Not in memory, try to get plugin and let it find the container + m.logger.Info("service not in memory for logs, checking Docker", zap.String("service", serviceName)) + + // Verify container exists in Docker first + if m.docker != nil { + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": serviceName, + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check for containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, + fmt.Sprintf("service %s is not running", serviceName)) + } + } + + // Get plugin from registry + servicePlugin, err := m.registry.GetService(serviceName) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("service %s not found in registry", serviceName)) + } + + // Let the plugin get logs (it will find the container by labels) + return servicePlugin.GetLogs(ctx, opts) +} + +// ListRunning returns a list of currently running services +func (m *Manager) ListRunning() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + // Start with services in memory + servicesMap := make(map[string]bool) + for name, state := range m.services { + if !state.stopped { + servicesMap[name] = true + } + } + + // Also check Docker for containers managed by gtool + if m.docker != nil { + ctx := context.Background() + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + }) + + if err != nil { + m.logger.Error("failed to list running containers", zap.Error(err)) + } else { + // Extract service names from labels + for _, container := range containers { + if container.State == "running" { + if serviceName, ok := container.Labels["service"]; ok { + servicesMap[serviceName] = true + } + } + } + } + } + + // Convert map to slice + services := make([]string, 0, len(servicesMap)) + for name := range servicesMap { + services = append(services, name) + } + + return services +} + +// StartAll starts all configured mock services +func (m *Manager) StartAll(ctx context.Context, serviceConfigs map[string]map[string]interface{}) error { + if m.orchestration.ParallelMocks { + return m.startParallel(ctx, serviceConfigs) + } + return m.startSequential(ctx, serviceConfigs) +} + +// startSequential starts services one by one +func (m *Manager) startSequential(ctx context.Context, serviceConfigs map[string]map[string]interface{}) error { + for serviceName, config := range serviceConfigs { + if err := m.Start(ctx, serviceName, config); err != nil { + if m.orchestration.CleanupOnFailure { + _ = m.StopAll(ctx) + } + return err + } + } + return nil +} + +// startParallel starts services in parallel +func (m *Manager) startParallel(ctx context.Context, serviceConfigs map[string]map[string]interface{}) error { + var wg sync.WaitGroup + errChan := make(chan error, len(serviceConfigs)) + + for serviceName, config := range serviceConfigs { + wg.Add(1) + go func(name string, cfg map[string]interface{}) { + defer wg.Done() + if err := m.Start(ctx, name, cfg); err != nil { + errChan <- fmt.Errorf("%s: %w", name, err) + } + }(serviceName, config) + } + + wg.Wait() + close(errChan) + + // Check for errors + var errors []error + for err := range errChan { + errors = append(errors, err) + } + + if len(errors) > 0 { + if m.orchestration.CleanupOnFailure { + _ = m.StopAll(ctx) + } + return fmt.Errorf("failed to start services: %v", errors) + } + + return nil +} diff --git a/internal/core/mock/manager_test.go b/internal/core/mock/manager_test.go new file mode 100644 index 0000000..2b2f8a7 --- /dev/null +++ b/internal/core/mock/manager_test.go @@ -0,0 +1,460 @@ +package mock + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/docker/docker/api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +// readyResult models a single IsReady response. The last element repeats. +type readyResult struct { + ready bool + err error +} + +// fakePlugin is a controllable ServicePlugin for testing the Manager. +type fakePlugin struct { + name string + + launchErr error + launchCalls int + + stopErr error + stopCalls int + + readyResults []readyResult + readyIdx int + + connInfo *plugin.ConnectionInfo + connErr error + + logs []string + logsErr error +} + +func (f *fakePlugin) Name() string { return f.name } + +func (f *fakePlugin) Launch(_ context.Context, _ map[string]interface{}) error { + f.launchCalls++ + return f.launchErr +} + +func (f *fakePlugin) IsReady(_ context.Context) (bool, error) { + if len(f.readyResults) == 0 { + return true, nil + } + idx := f.readyIdx + if idx >= len(f.readyResults) { + idx = len(f.readyResults) - 1 + } + f.readyIdx++ + r := f.readyResults[idx] + return r.ready, r.err +} + +func (f *fakePlugin) Stop(_ context.Context) error { + f.stopCalls++ + return f.stopErr +} + +func (f *fakePlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + return f.connInfo, f.connErr +} + +func (f *fakePlugin) GetLogs(_ context.Context, _ *plugin.LogOptions) ([]string, error) { + return f.logs, f.logsErr +} + +// fakeDocker is a controllable DockerClient. +type fakeDocker struct { + containers []types.Container + err error + calls int +} + +func (d *fakeDocker) ListContainersByLabels(_ context.Context, _ map[string]string) ([]types.Container, error) { + d.calls++ + return d.containers, d.err +} + +// fastOrchestration returns config that makes health-check loops finish quickly. +func fastOrchestration(parallel bool) config.OrchestrationConfig { + return config.OrchestrationConfig{ + ParallelMocks: parallel, + HealthCheckInterval: time.Millisecond, + HealthCheckRetries: 3, + CleanupOnFailure: true, + } +} + +func newRegistryWith(t *testing.T, plugins ...plugin.ServicePlugin) *plugin.Registry { + t.Helper() + reg := plugin.NewRegistry() + for _, p := range plugins { + require.NoError(t, reg.RegisterService(p)) + } + return reg +} + +func TestManager_Start(t *testing.T) { + ctx := context.Background() + + t.Run("starts service and stores state", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + + err := m.Start(ctx, "postgresql", nil) + + require.NoError(t, err) + assert.Equal(t, 1, p.launchCalls) + assert.Equal(t, []string{"postgresql"}, m.ListRunning()) + }) + + t.Run("idempotent when already running", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + err := m.Start(ctx, "postgresql", nil) + + require.NoError(t, err) + assert.Equal(t, 1, p.launchCalls, "should not relaunch an already running service") + }) + + t.Run("service not in registry", func(t *testing.T) { + m := NewManager(plugin.NewRegistry(), nil, fastOrchestration(false), nil) + + err := m.Start(ctx, "unknown", nil) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceFailed)) + }) + + t.Run("launch failure is wrapped", func(t *testing.T) { + p := &fakePlugin{name: "postgresql", launchErr: errors.New("boom")} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + + err := m.Start(ctx, "postgresql", nil) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceFailed)) + }) + + t.Run("never ready triggers cleanup and ErrServiceNotReady", func(t *testing.T) { + p := &fakePlugin{name: "postgresql", readyResults: []readyResult{{ready: false}}} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + + err := m.Start(ctx, "postgresql", nil) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceNotReady)) + assert.Equal(t, 1, p.stopCalls, "CleanupOnFailure should stop the service") + assert.Empty(t, m.ListRunning()) + }) + + t.Run("context cancelled while waiting", func(t *testing.T) { + cancelled, cancel := context.WithCancel(ctx) + cancel() + p := &fakePlugin{name: "postgresql", readyResults: []readyResult{{ready: false}}} + orch := fastOrchestration(false) + orch.HealthCheckInterval = time.Hour // ensure ctx.Done wins the select + m := NewManager(newRegistryWith(t, p), nil, orch, nil) + + err := m.Start(cancelled, "postgresql", nil) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceFailed)) + }) + + t.Run("becomes ready after a few attempts", func(t *testing.T) { + p := &fakePlugin{name: "postgresql", readyResults: []readyResult{ + {ready: false}, {ready: false}, {ready: true}, + }} + orch := fastOrchestration(false) + orch.HealthCheckRetries = 5 + m := NewManager(newRegistryWith(t, p), nil, orch, nil) + + err := m.Start(ctx, "postgresql", nil) + + require.NoError(t, err) + assert.Equal(t, []string{"postgresql"}, m.ListRunning()) + }) +} + +func TestManager_Stop(t *testing.T) { + ctx := context.Background() + + t.Run("stops in-memory service and removes it", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + err := m.Stop(ctx, "postgresql") + + require.NoError(t, err) + assert.Equal(t, 1, p.stopCalls) + assert.Empty(t, m.ListRunning()) + }) + + t.Run("plugin stop failure is wrapped and keeps state with error", func(t *testing.T) { + p := &fakePlugin{name: "postgresql", stopErr: errors.New("boom")} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + err := m.Stop(ctx, "postgresql") + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceFailed)) + }) + + t.Run("not in memory, no container in docker", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + dock := &fakeDocker{containers: nil} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), dock) + + err := m.Stop(ctx, "postgresql") + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceNotRunning)) + }) + + t.Run("not in memory, container exists, stops via plugin", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + dock := &fakeDocker{containers: []types.Container{{State: "running"}}} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), dock) + + err := m.Stop(ctx, "postgresql") + + require.NoError(t, err) + assert.Equal(t, 1, p.stopCalls) + }) + + t.Run("not in memory and not in registry", func(t *testing.T) { + dock := &fakeDocker{containers: []types.Container{{State: "running"}}} + m := NewManager(plugin.NewRegistry(), nil, fastOrchestration(false), dock) + + err := m.Stop(ctx, "unknown") + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceFailed)) + }) +} + +func TestManager_StopAll(t *testing.T) { + ctx := context.Background() + p1 := &fakePlugin{name: "postgresql"} + p2 := &fakePlugin{name: "kafka"} + m := NewManager(newRegistryWith(t, p1, p2), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + require.NoError(t, m.Start(ctx, "kafka", nil)) + + require.NoError(t, m.StopAll(ctx)) + + assert.Equal(t, 1, p1.stopCalls) + assert.Equal(t, 1, p2.stopCalls) + assert.Empty(t, m.ListRunning()) +} + +func TestManager_GetStatus(t *testing.T) { + ctx := context.Background() + + t.Run("in-memory running reports running with port", func(t *testing.T) { + p := &fakePlugin{ + name: "postgresql", + connInfo: &plugin.ConnectionInfo{Port: 5432}, + } + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + status, err := m.GetStatus(ctx, "postgresql") + + require.NoError(t, err) + assert.Equal(t, "running", status.Status) + assert.Equal(t, 5432, status.Port) + }) + + t.Run("in-memory with error reports error", func(t *testing.T) { + // Stop failure leaves the service in memory with its error set. + p := &fakePlugin{name: "postgresql", stopErr: errors.New("boom")} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + _ = m.Stop(ctx, "postgresql") + + status, err := m.GetStatus(ctx, "postgresql") + + require.NoError(t, err) + assert.Equal(t, "error", status.Status) + assert.Equal(t, "boom", status.Error) + }) + + t.Run("in-memory not ready reports error", func(t *testing.T) { + p := &fakePlugin{name: "postgresql", readyResults: []readyResult{ + {ready: true}, // Start + {ready: false}, // GetStatus + }} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + status, err := m.GetStatus(ctx, "postgresql") + + require.NoError(t, err) + assert.Equal(t, "error", status.Status) + }) + + t.Run("not in memory, found running in docker", func(t *testing.T) { + dock := &fakeDocker{containers: []types.Container{{ + State: "running", + Created: time.Now().Add(-time.Minute).Unix(), + Ports: []types.Port{{PublicPort: 5432}}, + }}} + m := NewManager(plugin.NewRegistry(), nil, fastOrchestration(false), dock) + + status, err := m.GetStatus(ctx, "postgresql") + + require.NoError(t, err) + assert.Equal(t, "running", status.Status) + assert.Equal(t, 5432, status.Port) + assert.Greater(t, status.Uptime, time.Duration(0)) + }) + + t.Run("not in memory, not in docker reports stopped", func(t *testing.T) { + m := NewManager(plugin.NewRegistry(), nil, fastOrchestration(false), &fakeDocker{}) + + status, err := m.GetStatus(ctx, "postgresql") + + require.NoError(t, err) + assert.Equal(t, "stopped", status.Status) + }) +} + +func TestManager_GetAllStatuses(t *testing.T) { + ctx := context.Background() + p := &fakePlugin{name: "postgresql", connInfo: &plugin.ConnectionInfo{Port: 5432}} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + statuses := m.GetAllStatuses(ctx) + + require.Len(t, statuses, 1) + assert.Equal(t, "postgresql", statuses[0].Name) + assert.Equal(t, "running", statuses[0].Status) +} + +func TestManager_GetLogs(t *testing.T) { + ctx := context.Background() + + t.Run("in-memory uses plugin logs", func(t *testing.T) { + p := &fakePlugin{name: "postgresql", logs: []string{"line1", "line2"}} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), nil) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + logs, err := m.GetLogs(ctx, "postgresql", &plugin.LogOptions{Tail: 10}) + + require.NoError(t, err) + assert.Equal(t, []string{"line1", "line2"}, logs) + }) + + t.Run("not in memory, container exists", func(t *testing.T) { + p := &fakePlugin{name: "postgresql", logs: []string{"line1"}} + dock := &fakeDocker{containers: []types.Container{{State: "running"}}} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), dock) + + logs, err := m.GetLogs(ctx, "postgresql", nil) + + require.NoError(t, err) + assert.Equal(t, []string{"line1"}, logs) + }) + + t.Run("not in memory, no container", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), &fakeDocker{}) + + _, err := m.GetLogs(ctx, "postgresql", nil) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceNotRunning)) + }) + + t.Run("docker error is wrapped", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + dock := &fakeDocker{err: errors.New("docker down")} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), dock) + + _, err := m.GetLogs(ctx, "postgresql", nil) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrDockerFailed)) + }) +} + +func TestManager_ListRunning(t *testing.T) { + ctx := context.Background() + + t.Run("merges in-memory and docker services", func(t *testing.T) { + p := &fakePlugin{name: "postgresql"} + dock := &fakeDocker{containers: []types.Container{{ + State: "running", + Labels: map[string]string{"service": "kafka"}, + }}} + m := NewManager(newRegistryWith(t, p), nil, fastOrchestration(false), dock) + require.NoError(t, m.Start(ctx, "postgresql", nil)) + + running := m.ListRunning() + + assert.ElementsMatch(t, []string{"postgresql", "kafka"}, running) + }) + + t.Run("ignores non-running docker containers", func(t *testing.T) { + dock := &fakeDocker{containers: []types.Container{{ + State: "exited", + Labels: map[string]string{"service": "kafka"}, + }}} + m := NewManager(plugin.NewRegistry(), nil, fastOrchestration(false), dock) + + assert.Empty(t, m.ListRunning()) + }) +} + +func TestManager_StartAll(t *testing.T) { + ctx := context.Background() + configs := map[string]map[string]interface{}{ + "postgresql": nil, + "kafka": nil, + } + + t.Run("sequential", func(t *testing.T) { + p1 := &fakePlugin{name: "postgresql"} + p2 := &fakePlugin{name: "kafka"} + m := NewManager(newRegistryWith(t, p1, p2), nil, fastOrchestration(false), nil) + + require.NoError(t, m.StartAll(ctx, configs)) + assert.ElementsMatch(t, []string{"postgresql", "kafka"}, m.ListRunning()) + }) + + t.Run("parallel", func(t *testing.T) { + p1 := &fakePlugin{name: "postgresql"} + p2 := &fakePlugin{name: "kafka"} + m := NewManager(newRegistryWith(t, p1, p2), nil, fastOrchestration(true), nil) + + require.NoError(t, m.StartAll(ctx, configs)) + assert.ElementsMatch(t, []string{"postgresql", "kafka"}, m.ListRunning()) + }) + + t.Run("sequential failure triggers cleanup", func(t *testing.T) { + p1 := &fakePlugin{name: "postgresql", launchErr: errors.New("boom")} + m := NewManager(newRegistryWith(t, p1), nil, fastOrchestration(false), + &fakeDocker{}) + + err := m.StartAll(ctx, map[string]map[string]interface{}{"postgresql": nil}) + require.Error(t, err) + }) +} diff --git a/internal/core/mock/stablemocks/launcher.go b/internal/core/mock/stablemocks/launcher.go new file mode 100644 index 0000000..13ea86e --- /dev/null +++ b/internal/core/mock/stablemocks/launcher.go @@ -0,0 +1,418 @@ +// Package stablemocks launches the third-party mocks for the component pipeline +// using the official public images for each tool, with a fixed docker run +// contract (network, ports, mounts, container names and readiness) so +// `gtool services up --stable` provides a reproducible mock environment that +// seeds data from test/component/mocks-data. +package stablemocks + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "go.uber.org/zap" + "gopkg.in/yaml.v3" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +const ( + // Official public images for each mocked tool. + postgresImage = "postgres:16-alpine" + pubsubImage = "gcr.io/google.com/cloudsdktool/cloud-sdk:emulators" + mountebankImage = "bbyars/mountebank:2.9.1" + + pubsubMockPort = "9085" + + readyAttempts = 60 + readyInterval = 3 * time.Second +) + +// Supported lists the mocks this launcher can start. +var Supported = []string{"postgresql", "pubsub", "mountebank"} + +// dockerClient is the subset of *docker.Client the launcher needs (kept small +// so tests can inject a fake). +type dockerClient interface { + EnsureImage(ctx context.Context, image string) error + CreateContainer(ctx context.Context, cfg *docker.ContainerConfig) (string, error) + StartContainer(ctx context.Context, id string) error + GetContainerLogs(ctx context.Context, id string, tail int) (string, error) + RemoveContainerByName(ctx context.Context, name string) (bool, error) +} + +// Launcher launches and stops the mock containers. +type Launcher struct { + docker dockerClient + logger *zap.Logger + mocksDataPath string + httpDo func(req *http.Request) (*http.Response, error) +} + +// New builds a Launcher resolving the mocks-data directory from the working +// directory ($PWD/test/component/mocks-data). +func New(d dockerClient, logger *zap.Logger) (*Launcher, error) { + if logger == nil { + logger = zap.NewNop() + } + wd, err := os.Getwd() + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to resolve working directory") + } + return &Launcher{ + docker: d, + logger: logger, + mocksDataPath: filepath.Join(wd, "test", "component", "mocks-data"), + httpDo: (&http.Client{Timeout: 10 * time.Second}).Do, + }, nil +} + +// pubsubTopic is a topic and its subscriptions, created via the emulator REST +// API once it is ready. +type pubsubTopic struct { + name string + subs []string +} + +type launchSpec struct { + name string + image string + networkMode string + entrypoint []string + cmd []string + ports map[string]string // container port -> host port + mounts []docker.Mount + env []string + readyLogs []string // every entry must be present in the logs to be ready + readyHTTP string // GET url that must return 200 to be ready + + // pubsub-only: resources created over REST after readiness. + projectID string + topics []pubsubTopic +} + +// Up launches the given services (or all configured mocks), waits until each +// one is ready and seeds any post-start resources. +func (l *Launcher) Up(ctx context.Context, services []string, cfg *config.Config) error { + if len(services) == 0 { + services = cfg.ThirdParty.Mocks + } + if len(services) == 0 { + return gtErrors.New(gtErrors.ErrInvalidArgument, "no services specified and none configured") + } + + specs := make([]*launchSpec, 0, len(services)) + for _, name := range services { + spec, err := l.specFor(name, cfg) + if err != nil { + return err + } + specs = append(specs, spec) + } + + for _, spec := range specs { + fmt.Printf("🚀 Launching %s...\n", spec.name) + if err := l.launchOne(ctx, spec); err != nil { + return err + } + } + + fmt.Printf("⏳ Waiting for mocks to be ready...\n") + for _, spec := range specs { + if err := l.waitReady(ctx, spec); err != nil { + return err + } + if len(spec.topics) > 0 { + if err := l.seedPubsub(ctx, spec); err != nil { + return err + } + } + fmt.Printf("✅ %s ready\n", spec.name) + } + + return nil +} + +// Down removes the fixed-name mock containers. +func (l *Launcher) Down(ctx context.Context, services []string) error { + if len(services) == 0 { + services = Supported + } + for _, name := range services { + removed, err := l.docker.RemoveContainerByName(ctx, name) + if err != nil { + return err + } + if removed { + fmt.Printf("🛑 %s stopped\n", name) + } + } + return nil +} + +func (l *Launcher) launchOne(ctx context.Context, spec *launchSpec) error { + if _, err := l.docker.RemoveContainerByName(ctx, spec.name); err != nil { + return err + } + if err := l.docker.EnsureImage(ctx, spec.image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, + fmt.Sprintf("failed to ensure image for %s", spec.name)) + } + + cc := &docker.ContainerConfig{ + Image: spec.image, + Name: spec.name, + Entrypoint: spec.entrypoint, + Cmd: spec.cmd, + Env: spec.env, + PortBindings: spec.ports, + Mounts: spec.mounts, + NetworkMode: spec.networkMode, + Init: true, + Labels: map[string]string{ + "managed-by": "gtool", + "gtool-stable-mock": spec.name, + }, + } + + id, err := l.docker.CreateContainer(ctx, cc) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, + fmt.Sprintf("failed to create %s container", spec.name)) + } + if err := l.docker.StartContainer(ctx, id); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, + fmt.Sprintf("failed to start %s container", spec.name)) + } + return nil +} + +func (l *Launcher) waitReady(ctx context.Context, spec *launchSpec) error { + if spec.readyHTTP != "" { + return l.waitHTTP(ctx, spec.readyHTTP) + } + // No readiness signal (e.g. mountebank loads its imposters at startup). + if len(spec.readyLogs) == 0 { + return nil + } + + for attempt := 0; attempt < readyAttempts; attempt++ { + logs, err := l.docker.GetContainerLogs(ctx, spec.name, 500) + if err == nil && containsAll(logs, spec.readyLogs) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(readyInterval): + } + } + + return gtErrors.New(gtErrors.ErrServiceTimeout, + fmt.Sprintf("timeout waiting for %s to be ready", spec.name)) +} + +func (l *Launcher) waitHTTP(ctx context.Context, url string) error { + do := l.httpClient() + for attempt := 0; attempt < readyAttempts; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build readiness request") + } + if resp, err := do(req); err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(readyInterval): + } + } + return gtErrors.New(gtErrors.ErrServiceTimeout, fmt.Sprintf("timeout waiting for %s", url)) +} + +func (l *Launcher) specFor(name string, cfg *config.Config) (*launchSpec, error) { + switch name { + case "mountebank": + dir := filepath.Join(l.mocksDataPath, "mountebank") + if err := requireDir(dir); err != nil { + return nil, err + } + + return &launchSpec{ + name: name, + image: mountebankImage, + networkMode: "host", + entrypoint: []string{"mb"}, + cmd: []string{"start", "--configfile", "/imposters/imposters.ejs", "--allowInjection"}, + mounts: []docker.Mount{{Type: "bind", Source: dir, Target: "/imposters"}}, + }, nil + + case "postgresql": + dir := filepath.Join(l.mocksDataPath, "postgresql") + if err := requireSQLData(dir); err != nil { + return nil, err + } + + return &launchSpec{ + name: name, + image: postgresImage, + ports: map[string]string{"5432": "5432"}, + mounts: []docker.Mount{{Type: "bind", Source: dir, Target: "/docker-entrypoint-initdb.d", ReadOnly: true}}, + env: []string{"POSTGRES_PASSWORD=postgres"}, + readyLogs: []string{ + "PostgreSQL init process complete; ready for start up", + "database system is ready to accept connections", + }, + }, nil + + case "pubsub": + projectID, topics, err := parsePubsub(cfg) + if err != nil { + return nil, err + } + return &launchSpec{ + name: name, + image: pubsubImage, + cmd: []string{ + "gcloud", "beta", "emulators", "pubsub", "start", + "--host-port=0.0.0.0:8085", + "--project=" + projectID, + }, + ports: map[string]string{"8085": pubsubMockPort}, + readyHTTP: fmt.Sprintf("http://localhost:%s/v1/projects/%s/topics", pubsubMockPort, projectID), + projectID: projectID, + topics: topics, + }, nil + + default: + return nil, gtErrors.New(gtErrors.ErrInvalidArgument, + fmt.Sprintf("%q is not supported in stable mode (supported: %s)", name, strings.Join(Supported, ", "))) + } +} + +// seedPubsub creates the configured topics and subscriptions over the emulator +// REST API once it is serving requests. +func (l *Launcher) seedPubsub(ctx context.Context, spec *launchSpec) error { + base := fmt.Sprintf("http://localhost:%s/v1/projects/%s", pubsubMockPort, spec.projectID) + for _, t := range spec.topics { + if err := l.putResource(ctx, fmt.Sprintf("%s/topics/%s", base, t.name), nil); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to create topic %s", t.name)) + } + for _, sub := range t.subs { + body := []byte(fmt.Sprintf(`{"topic":"projects/%s/topics/%s"}`, spec.projectID, t.name)) + if err := l.putResource(ctx, fmt.Sprintf("%s/subscriptions/%s", base, sub), body); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to create subscription %s", sub)) + } + } + } + return nil +} + +func (l *Launcher) putResource(ctx context.Context, url string, body []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := l.httpClient()(req) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "request failed") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict { + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("unexpected status %d for %s", resp.StatusCode, url)) + } + return nil +} + +func (l *Launcher) httpClient() func(*http.Request) (*http.Response, error) { + if l.httpDo != nil { + return l.httpDo + } + return http.DefaultClient.Do +} + +// parsePubsub reads the pubsub mock-config into a project id and its topics. +func parsePubsub(cfg *config.Config) (string, []pubsubTopic, error) { + raw, ok := cfg.ThirdParty.MockConfig["pubsub"] + if !ok || raw == nil { + return "", nil, gtErrors.New(gtErrors.ErrConfigInvalid, + "pubsub mock-config is required to use the pubsub mock") + } + + data, err := yaml.Marshal(raw) + if err != nil { + return "", nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to encode pubsub config") + } + + var pc struct { + ProjectID string `yaml:"project-id"` + Topics []struct { + TopicID string `yaml:"topic-id"` + SubscriptionIDs []string `yaml:"subscription-ids"` + } `yaml:"topics"` + } + if err := yaml.Unmarshal(data, &pc); err != nil { + return "", nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to parse pubsub config") + } + if pc.ProjectID == "" { + return "", nil, gtErrors.New(gtErrors.ErrConfigInvalid, "pubsub mock-config requires project-id") + } + + topics := make([]pubsubTopic, 0, len(pc.Topics)) + for _, t := range pc.Topics { + topics = append(topics, pubsubTopic{name: t.TopicID, subs: t.SubscriptionIDs}) + } + return pc.ProjectID, topics, nil +} + +func containsAll(haystack string, needles []string) bool { + for _, n := range needles { + if !strings.Contains(haystack, n) { + return false + } + } + return true +} + +func requireDir(dir string) error { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return gtErrors.New(gtErrors.ErrInvalidArgument, + fmt.Sprintf("mock data directory not found: %s", dir)) + } + return nil +} + +func requireSQLData(dir string) error { + if err := requireDir(dir); err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to read postgresql mock data") + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + return nil + } + } + return gtErrors.New(gtErrors.ErrInvalidArgument, + fmt.Sprintf("no .sql data files found in %s", dir)) +} diff --git a/internal/core/mock/stablemocks/launcher_test.go b/internal/core/mock/stablemocks/launcher_test.go new file mode 100644 index 0000000..ac45b40 --- /dev/null +++ b/internal/core/mock/stablemocks/launcher_test.go @@ -0,0 +1,170 @@ +package stablemocks + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "gopkg.in/yaml.v3" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/config" +) + +// fakeDocker records interactions and lets tests drive readiness. +type fakeDocker struct { + created []*docker.ContainerConfig + started []string + removed []string + logs string + ensureErr error +} + +func (f *fakeDocker) EnsureImage(_ context.Context, _ string) error { return f.ensureErr } +func (f *fakeDocker) CreateContainer(_ context.Context, cfg *docker.ContainerConfig) (string, error) { + f.created = append(f.created, cfg) + return "id-" + cfg.Name, nil +} +func (f *fakeDocker) StartContainer(_ context.Context, id string) error { + f.started = append(f.started, id) + return nil +} +func (f *fakeDocker) GetContainerLogs(_ context.Context, _ string, _ int) (string, error) { + return f.logs, nil +} +func (f *fakeDocker) RemoveContainerByName(_ context.Context, name string) (bool, error) { + f.removed = append(f.removed, name) + return true, nil +} + +type fakeHTTP struct { + reqs []*http.Request +} + +func (h *fakeHTTP) do(req *http.Request) (*http.Response, error) { + h.reqs = append(h.reqs, req) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil +} + +func mockConfig(t *testing.T, yamlStr string) map[string]interface{} { + t.Helper() + var m map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(yamlStr), &m)) + return m +} + +func TestParsePubsub(t *testing.T) { + cfg := &config.Config{} + cfg.ThirdParty.MockConfig = map[string]interface{}{ + "pubsub": mockConfig(t, ` +project-id: my-project +topics: + - topic-id: topic-a + subscription-ids: [sub-a1, sub-a2] + - topic-id: topic-b + - topic-id: topic-c + subscription-ids: [sub-c1] +`), + } + + projectID, topics, err := parsePubsub(cfg) + require.NoError(t, err) + assert.Equal(t, "my-project", projectID) + require.Len(t, topics, 3) + assert.Equal(t, "topic-a", topics[0].name) + assert.Equal(t, []string{"sub-a1", "sub-a2"}, topics[0].subs) + assert.Equal(t, "topic-b", topics[1].name) + assert.Empty(t, topics[1].subs) + assert.Equal(t, "topic-c", topics[2].name) + assert.Equal(t, []string{"sub-c1"}, topics[2].subs) +} + +func TestParsePubsubErrors(t *testing.T) { + tests := []struct { + name string + cfg map[string]interface{} + }{ + {name: "missing pubsub", cfg: map[string]interface{}{}}, + {name: "missing project-id", cfg: map[string]interface{}{ + "pubsub": mockConfig(t, "topics: []"), // present but no project-id + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.ThirdParty.MockConfig = tt.cfg + _, _, err := parsePubsub(cfg) + require.Error(t, err) + }) + } +} + +func TestContainsAll(t *testing.T) { + assert.True(t, containsAll("abc ready def done", []string{"ready", "done"})) + assert.False(t, containsAll("abc ready", []string{"ready", "done"})) + assert.True(t, containsAll("anything", nil)) +} + +func TestUpLaunchesPubsubWithPublicImage(t *testing.T) { + fd := &fakeDocker{} + fh := &fakeHTTP{} + l := &Launcher{docker: fd, logger: zap.NewNop(), mocksDataPath: t.TempDir(), httpDo: fh.do} + + cfg := &config.Config{} + cfg.ThirdParty.MockConfig = map[string]interface{}{ + "pubsub": mockConfig(t, ` +project-id: p1 +topics: + - topic-id: t1 + subscription-ids: [s1] +`), + } + + require.NoError(t, l.Up(context.Background(), []string{"pubsub"}, cfg)) + + require.Len(t, fd.created, 1) + cc := fd.created[0] + assert.Equal(t, "pubsub", cc.Name) + assert.Equal(t, pubsubImage, cc.Image) + assert.Equal(t, pubsubMockPort, cc.PortBindings["8085"]) + assert.True(t, cc.Init) + assert.Contains(t, cc.Cmd, "--project=p1") + for _, e := range cc.Env { + assert.NotContains(t, e, "TOPICS=") + } + assert.Equal(t, []string{"id-pubsub"}, fd.started) + + var gets, puts []string + for _, r := range fh.reqs { + switch r.Method { + case http.MethodGet: + gets = append(gets, r.URL.Path) + case http.MethodPut: + puts = append(puts, r.URL.Path) + } + } + require.NotEmpty(t, gets) + require.Len(t, puts, 2) + assert.Contains(t, puts[0], "/topics/t1") + assert.Contains(t, puts[1], "/subscriptions/s1") +} + +func TestUpUnsupportedService(t *testing.T) { + fd := &fakeDocker{} + l := &Launcher{docker: fd, logger: zap.NewNop(), mocksDataPath: t.TempDir()} + err := l.Up(context.Background(), []string{"kafka"}, &config.Config{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not supported") +} + +func TestDownRemovesContainers(t *testing.T) { + fd := &fakeDocker{} + l := &Launcher{docker: fd, logger: zap.NewNop()} + require.NoError(t, l.Down(context.Background(), []string{"pubsub", "postgresql"})) + assert.Equal(t, []string{"pubsub", "postgresql"}, fd.removed) +} diff --git a/internal/core/orchestrator/orchestrator.go b/internal/core/orchestrator/orchestrator.go new file mode 100644 index 0000000..68738cb --- /dev/null +++ b/internal/core/orchestrator/orchestrator.go @@ -0,0 +1,143 @@ +package orchestrator + +import ( + "context" + + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +// MockManager starts and stops the mock services. *mock.Manager satisfies it. +type MockManager interface { + StartAll(ctx context.Context, serviceConfigs map[string]map[string]interface{}) error + StopAll(ctx context.Context) error +} + +// AppManager starts and stops the application under test. *app.DockerManager +// satisfies it. +type AppManager interface { + Start(ctx context.Context, cfg *plugin.AppConfig) error + Stop(ctx context.Context) error +} + +// TestRunner executes the test suite. Until Phase 4 lands this is a stub. +type TestRunner interface { + Run(ctx context.Context, cfg *config.Config) (*plugin.TestResult, error) +} + +// Result summarizes a pipeline run. +type Result struct { + MocksStarted bool + AppStarted bool + Test *plugin.TestResult +} + +// Orchestrator coordinates the full component-test pipeline: start mocks, start +// the application, run tests, then tear everything down. +type Orchestrator struct { + config *config.Config + mocks MockManager + app AppManager + tests TestRunner + logger *zap.Logger +} + +// NewOrchestrator wires the orchestrator with its phase managers. +func NewOrchestrator(cfg *config.Config, mocks MockManager, app AppManager, tests TestRunner, logger *zap.Logger) *Orchestrator { + if logger == nil { + logger = zap.NewNop() + } + return &Orchestrator{ + config: cfg, + mocks: mocks, + app: app, + tests: tests, + logger: logger, + } +} + +// Run executes the pipeline. Whatever was started is always torn down before +// Run returns, even on failure or context cancellation. +func (o *Orchestrator) Run(ctx context.Context) (*Result, error) { + res := &Result{} + + defer o.cleanup(res) + + // Phase 1: mocks + serviceConfigs := buildServiceConfigs(o.config) + if len(serviceConfigs) > 0 { + o.logger.Info("phase: starting mocks", zap.Int("count", len(serviceConfigs))) + if err := o.mocks.StartAll(ctx, serviceConfigs); err != nil { + return res, gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to start mocks") + } + res.MocksStarted = true + } + + // Phase 2: application + appConfig := buildAppConfig(o.config) + if appConfig.DockerImage != "" { + o.logger.Info("phase: starting application", zap.String("image", appConfig.DockerImage)) + if err := o.app.Start(ctx, appConfig); err != nil { + return res, gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to start application") + } + res.AppStarted = true + } + + // Phase 3: tests + o.logger.Info("phase: running tests") + testResult, err := o.tests.Run(ctx, o.config) + if err != nil { + return res, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "test execution failed") + } + res.Test = testResult + + o.logger.Info("pipeline finished") + return res, nil +} + +// cleanup tears down the application and mocks in reverse order. It uses a fresh +// context so teardown still runs when the pipeline was cancelled, and is +// best-effort: failures are logged, not returned. +func (o *Orchestrator) cleanup(res *Result) { + cleanupCtx := context.Background() + + if res.AppStarted { + o.logger.Info("cleanup: stopping application") + if err := o.app.Stop(cleanupCtx); err != nil { + o.logger.Error("failed to stop application during cleanup", zap.Error(err)) + } + } + if res.MocksStarted { + o.logger.Info("cleanup: stopping mocks") + if err := o.mocks.StopAll(cleanupCtx); err != nil { + o.logger.Error("failed to stop mocks during cleanup", zap.Error(err)) + } + } +} + +// buildServiceConfigs turns the configured mocks into the per-service config map +// the mock manager expects. +func buildServiceConfigs(cfg *config.Config) map[string]map[string]interface{} { + out := make(map[string]map[string]interface{}, len(cfg.ThirdParty.Mocks)) + for _, name := range cfg.ThirdParty.Mocks { + m, _ := cfg.ThirdParty.MockConfig[name].(map[string]interface{}) + if m == nil { + m = map[string]interface{}{} + } + out[name] = m + } + return out +} + +// buildAppConfig maps the file configuration to a plugin.AppConfig. +func buildAppConfig(cfg *config.Config) *plugin.AppConfig { + return &plugin.AppConfig{ + BinaryName: cfg.AppConfig.BinaryName, + BinaryPath: cfg.AppConfig.BinaryPath, + DockerImage: cfg.AppConfig.DockerImage, + Port: cfg.AppConfig.Port, + Environment: cfg.AppConfig.Environment, + } +} diff --git a/internal/core/orchestrator/orchestrator_test.go b/internal/core/orchestrator/orchestrator_test.go new file mode 100644 index 0000000..04ddac2 --- /dev/null +++ b/internal/core/orchestrator/orchestrator_test.go @@ -0,0 +1,164 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +type fakeMocks struct { + startErr error + startCalls int + stopCalls int +} + +func (f *fakeMocks) StartAll(_ context.Context, _ map[string]map[string]interface{}) error { + f.startCalls++ + return f.startErr +} +func (f *fakeMocks) StopAll(_ context.Context) error { + f.stopCalls++ + return nil +} + +type fakeApp struct { + startErr error + startCalls int + stopCalls int +} + +func (f *fakeApp) Start(_ context.Context, _ *plugin.AppConfig) error { + f.startCalls++ + return f.startErr +} +func (f *fakeApp) Stop(_ context.Context) error { + f.stopCalls++ + return nil +} + +type fakeTests struct { + err error + called bool +} + +func (f *fakeTests) Run(_ context.Context, _ *config.Config) (*plugin.TestResult, error) { + f.called = true + return &plugin.TestResult{Total: 1, Passed: 1}, f.err +} + +// cfgWith builds a config with the given mocks and docker image. +func cfgWith(mocks []string, image string) *config.Config { + cfg := config.DefaultConfig() + cfg.ThirdParty.Mocks = mocks + cfg.AppConfig.DockerImage = image + return cfg +} + +func TestOrchestrator_Run_FullPipeline(t *testing.T) { + mocks := &fakeMocks{} + app := &fakeApp{} + tests := &fakeTests{} + o := NewOrchestrator(cfgWith([]string{"postgresql"}, "myapp:latest"), mocks, app, tests, nil) + + res, err := o.Run(context.Background()) + + require.NoError(t, err) + assert.True(t, res.MocksStarted) + assert.True(t, res.AppStarted) + assert.True(t, tests.called) + // Everything started must be torn down. + assert.Equal(t, 1, mocks.startCalls) + assert.Equal(t, 1, mocks.stopCalls) + assert.Equal(t, 1, app.startCalls) + assert.Equal(t, 1, app.stopCalls) +} + +func TestOrchestrator_Run_NoMocksNoApp(t *testing.T) { + mocks := &fakeMocks{} + app := &fakeApp{} + tests := &fakeTests{} + o := NewOrchestrator(cfgWith(nil, ""), mocks, app, tests, nil) + + res, err := o.Run(context.Background()) + + require.NoError(t, err) + assert.False(t, res.MocksStarted) + assert.False(t, res.AppStarted) + assert.True(t, tests.called, "tests run even with no mocks/app") + assert.Equal(t, 0, mocks.startCalls) + assert.Equal(t, 0, app.startCalls) +} + +func TestOrchestrator_Run_MockFailure(t *testing.T) { + mocks := &fakeMocks{startErr: errors.New("boom")} + app := &fakeApp{} + tests := &fakeTests{} + o := NewOrchestrator(cfgWith([]string{"postgresql"}, "myapp:latest"), mocks, app, tests, nil) + + res, err := o.Run(context.Background()) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrServiceFailed)) + assert.False(t, res.AppStarted) + assert.False(t, tests.called, "tests must not run if mocks failed") + assert.Equal(t, 0, app.startCalls, "app must not start if mocks failed") + // Mocks were not marked started, so StopAll is not called. + assert.Equal(t, 0, mocks.stopCalls) +} + +func TestOrchestrator_Run_AppFailure_CleansUpMocks(t *testing.T) { + mocks := &fakeMocks{} + app := &fakeApp{startErr: errors.New("boom")} + tests := &fakeTests{} + o := NewOrchestrator(cfgWith([]string{"postgresql"}, "myapp:latest"), mocks, app, tests, nil) + + res, err := o.Run(context.Background()) + + require.Error(t, err) + assert.False(t, tests.called, "tests must not run if app failed") + assert.True(t, res.MocksStarted) + assert.Equal(t, 1, mocks.stopCalls, "mocks started earlier must be cleaned up") + assert.Equal(t, 0, app.stopCalls, "app never started, so not stopped") +} + +func TestOrchestrator_Run_TestFailure_CleansUpEverything(t *testing.T) { + mocks := &fakeMocks{} + app := &fakeApp{} + tests := &fakeTests{err: errors.New("tests failed")} + o := NewOrchestrator(cfgWith([]string{"postgresql"}, "myapp:latest"), mocks, app, tests, nil) + + _, err := o.Run(context.Background()) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrTestFailed)) + assert.Equal(t, 1, app.stopCalls) + assert.Equal(t, 1, mocks.stopCalls) +} + +func TestStubTestRunner(t *testing.T) { + res, err := NewStubTestRunner(nil).Run(context.Background(), config.DefaultConfig()) + + require.NoError(t, err) + require.NotNil(t, res) +} + +func TestBuildServiceConfigs(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ThirdParty.Mocks = []string{"postgresql", "kafka"} + cfg.ThirdParty.MockConfig = map[string]interface{}{ + "postgresql": map[string]interface{}{"port": "5432"}, + } + + got := buildServiceConfigs(cfg) + + require.Len(t, got, 2) + assert.Equal(t, "5432", got["postgresql"]["port"]) + assert.NotNil(t, got["kafka"], "mock without config still gets an empty map") +} diff --git a/internal/core/orchestrator/stub.go b/internal/core/orchestrator/stub.go new file mode 100644 index 0000000..7b5dd35 --- /dev/null +++ b/internal/core/orchestrator/stub.go @@ -0,0 +1,30 @@ +package orchestrator + +import ( + "context" + + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" + "go.uber.org/zap" +) + +// StubTestRunner is a placeholder TestRunner used until the Phase 4 test +// executors (Karate) are implemented. It runs no tests and reports an empty +// successful result so the rest of the pipeline can be exercised end to end. +type StubTestRunner struct { + logger *zap.Logger +} + +// NewStubTestRunner creates a stub test runner. +func NewStubTestRunner(logger *zap.Logger) *StubTestRunner { + if logger == nil { + logger = zap.NewNop() + } + return &StubTestRunner{logger: logger} +} + +// Run logs that test execution is not yet implemented and returns an empty result. +func (s *StubTestRunner) Run(_ context.Context, _ *config.Config) (*plugin.TestResult, error) { + s.logger.Warn("test execution is not implemented yet (Phase 4); skipping tests") + return &plugin.TestResult{}, nil +} diff --git a/internal/core/test/executor.go b/internal/core/test/executor.go new file mode 100644 index 0000000..f55b637 --- /dev/null +++ b/internal/core/test/executor.go @@ -0,0 +1,21 @@ +package test + +import ( + "context" + + "github.com/oswaldom-code/gtool/internal/plugin" +) + +type Manager struct { + executor plugin.TestExecutor +} + +func NewManager(executor plugin.TestExecutor) *Manager { + return &Manager{ + executor: executor, + } +} + +func (m *Manager) Execute(ctx context.Context, config *plugin.TestConfig) (*plugin.TestResult, error) { + return nil, nil +} diff --git a/internal/core/test/karate.go b/internal/core/test/karate.go new file mode 100644 index 0000000..26f0b2c --- /dev/null +++ b/internal/core/test/karate.go @@ -0,0 +1,185 @@ +package test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + // DefaultLauncherImage is the Karate backend test launcher image (see + // test-launcher/). Build it locally with `make test-launcher-image`; override + // it via NewKarateRunner when needed. + DefaultLauncherImage = "gtool/test-launcher-back:latest" + + featuresTarget = "/app/features" + reportsTarget = "/app/target/karate-reports" +) + +// DockerClient is the subset of *docker.Client used by the Karate runner. +type DockerClient interface { + PullImage(ctx context.Context, image string) error + CreateContainer(ctx context.Context, config *docker.ContainerConfig) (string, error) + StartContainer(ctx context.Context, containerID string) error + WaitForContainer(ctx context.Context, containerID string, condition container.WaitCondition) error + InspectContainer(ctx context.Context, containerID string) (*dockertypes.ContainerJSON, error) + GetContainerLogs(ctx context.Context, containerID string, tail int) (string, error) + RemoveContainer(ctx context.Context, containerID string, force bool) error +} + +// KarateRunner executes a Karate backend test suite by running the +// test-launcher-back container against the already-running app and mocks. It +// satisfies the orchestrator's TestRunner interface. +type KarateRunner struct { + docker DockerClient + image string + logger *zap.Logger +} + +// NewKarateRunner creates a Karate runner. An empty image uses DefaultLauncherImage. +func NewKarateRunner(dockerClient DockerClient, image string, logger *zap.Logger) *KarateRunner { + if image == "" { + image = DefaultLauncherImage + } + if logger == nil { + logger = zap.NewNop() + } + return &KarateRunner{docker: dockerClient, image: image, logger: logger} +} + +// Run mounts the feature files into the launcher container, runs it on the host +// network (so the tests can reach the app and mocks on localhost), and reports +// the outcome from the container exit code. +func (k *KarateRunner) Run(ctx context.Context, cfg *config.Config) (*plugin.TestResult, error) { + tc := cfg.TestConfig + if tc.FeaturesPath == "" { + k.logger.Warn("no test-config.features-path configured; skipping tests") + return &plugin.TestResult{}, nil + } + + mounts, err := k.buildMounts(tc) + if err != nil { + return nil, err + } + + var env []string + if tc.Tags != "" { + env = append(env, "TAGS="+tc.Tags) + } + + k.logger.Info("pulling test launcher", zap.String("image", k.image)) + if err := k.docker.PullImage(ctx, k.image); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to pull test launcher image") + } + + containerID, err := k.docker.CreateContainer(ctx, &docker.ContainerConfig{ + Image: k.image, + Name: fmt.Sprintf("gtool-test-%d", time.Now().Unix()), + Env: env, + Mounts: mounts, + NetworkMode: "host", + Labels: map[string]string{ + "managed-by": "gtool", + "gtool-role": "test", + }, + }) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to create test container") + } + defer func() { + if err := k.docker.RemoveContainer(context.Background(), containerID, true); err != nil { + k.logger.Warn("failed to remove test container", zap.Error(err)) + } + }() + + start := time.Now() + if err := k.docker.StartContainer(ctx, containerID); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to start test container") + } + + k.logger.Info("running tests", zap.String("features", tc.FeaturesPath), zap.String("tags", tc.Tags)) + if err := k.docker.WaitForContainer(ctx, containerID, container.WaitConditionNotRunning); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed while waiting for tests to finish") + } + + exitCode := 0 + if inspect, err := k.docker.InspectContainer(ctx, containerID); err == nil && inspect.State != nil { + exitCode = inspect.State.ExitCode + } + + logs, _ := k.docker.GetContainerLogs(ctx, containerID, 1000) + k.logger.Info("tests finished", zap.Int("exit-code", exitCode)) + + return buildResult(exitCode, time.Since(start), tc.ReportsPath, logs), nil +} + +// buildMounts builds the bind mounts for features (read-only) and reports. +func (k *KarateRunner) buildMounts(tc config.TestConfig) ([]docker.Mount, error) { + featuresAbs, err := filepath.Abs(tc.FeaturesPath) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "invalid features-path") + } + mounts := []docker.Mount{ + {Type: "bind", Source: featuresAbs, Target: featuresTarget, ReadOnly: true}, + } + + if tc.ReportsPath != "" { + reportsAbs, err := filepath.Abs(tc.ReportsPath) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "invalid reports-path") + } + if err := os.MkdirAll(reportsAbs, 0o755); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to create reports directory") + } + mounts = append(mounts, docker.Mount{Type: "bind", Source: reportsAbs, Target: reportsTarget}) + } + + return mounts, nil +} + +// buildResult turns the container exit code into a TestResult. A zero exit code +// is a pass; any other value is a failure carrying the tail of the logs. +func buildResult(exitCode int, duration time.Duration, reportsPath, logs string) *plugin.TestResult { + result := &plugin.TestResult{ + Total: 1, + Duration: duration, + ReportURL: reportsPath, + } + if exitCode == 0 { + result.Passed = 1 + return result + } + + result.Failed = 1 + result.Failures = []plugin.TestFailure{{ + Name: "karate suite", + Message: fmt.Sprintf("test launcher exited with code %d", exitCode), + Stack: lastLines(logs, 20), + }} + return result +} + +// lastLines returns at most n trailing lines of s. +func lastLines(s string, n int) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} diff --git a/internal/core/test/karate_test.go b/internal/core/test/karate_test.go new file mode 100644 index 0000000..0765a91 --- /dev/null +++ b/internal/core/test/karate_test.go @@ -0,0 +1,144 @@ +package test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/config" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +type fakeDocker struct { + exitCode int + pullErr error + createErr error + startErr error + waitErr error + + created *docker.ContainerConfig + removed bool + logsText string +} + +func (d *fakeDocker) PullImage(_ context.Context, _ string) error { return d.pullErr } +func (d *fakeDocker) CreateContainer(_ context.Context, c *docker.ContainerConfig) (string, error) { + d.created = c + if d.createErr != nil { + return "", d.createErr + } + return "test-container", nil +} +func (d *fakeDocker) StartContainer(_ context.Context, _ string) error { return d.startErr } +func (d *fakeDocker) WaitForContainer(_ context.Context, _ string, _ container.WaitCondition) error { + return d.waitErr +} +func (d *fakeDocker) InspectContainer(_ context.Context, _ string) (*dockertypes.ContainerJSON, error) { + return &dockertypes.ContainerJSON{ + ContainerJSONBase: &dockertypes.ContainerJSONBase{ + State: &dockertypes.ContainerState{ExitCode: d.exitCode}, + }, + }, nil +} +func (d *fakeDocker) GetContainerLogs(_ context.Context, _ string, _ int) (string, error) { + return d.logsText, nil +} +func (d *fakeDocker) RemoveContainer(_ context.Context, _ string, _ bool) error { + d.removed = true + return nil +} + +func cfgWithFeatures(t *testing.T) *config.Config { + t.Helper() + cfg := config.DefaultConfig() + cfg.TestConfig.FeaturesPath = t.TempDir() // exists, abs-resolvable + cfg.TestConfig.Tags = "smoke,regression" + return cfg +} + +func TestKarateRunner_Skip_NoFeatures(t *testing.T) { + d := &fakeDocker{} + r := NewKarateRunner(d, "", nil) + + res, err := r.Run(context.Background(), config.DefaultConfig()) + + require.NoError(t, err) + assert.Equal(t, 0, res.Total, "no features means nothing ran") + assert.Nil(t, d.created, "docker must not be touched when skipping") +} + +func TestKarateRunner_Pass(t *testing.T) { + d := &fakeDocker{exitCode: 0} + r := NewKarateRunner(d, "", nil) + + res, err := r.Run(context.Background(), cfgWithFeatures(t)) + + require.NoError(t, err) + assert.Equal(t, 1, res.Total) + assert.Equal(t, 1, res.Passed) + assert.Equal(t, 0, res.Failed) + assert.True(t, d.removed, "container must be cleaned up") + // Tags propagated and host networking used so tests reach localhost. + assert.Contains(t, d.created.Env, "TAGS=smoke,regression") + assert.Equal(t, "host", d.created.NetworkMode) + assert.Equal(t, DefaultLauncherImage, d.created.Image) +} + +func TestKarateRunner_Fail_NonZeroExit(t *testing.T) { + d := &fakeDocker{exitCode: 1, logsText: "scenario failed\nassert error"} + r := NewKarateRunner(d, "", nil) + + res, err := r.Run(context.Background(), cfgWithFeatures(t)) + + require.NoError(t, err, "a test failure is data, not an execution error") + assert.Equal(t, 1, res.Failed) + require.Len(t, res.Failures, 1) + assert.Contains(t, res.Failures[0].Message, "code 1") +} + +func TestKarateRunner_MountsFeaturesAndReports(t *testing.T) { + cfg := cfgWithFeatures(t) + cfg.TestConfig.ReportsPath = filepath.Join(t.TempDir(), "reports") + d := &fakeDocker{exitCode: 0} + r := NewKarateRunner(d, "", nil) + + _, err := r.Run(context.Background(), cfg) + require.NoError(t, err) + + require.Len(t, d.created.Mounts, 2) + assert.Equal(t, featuresTarget, d.created.Mounts[0].Target) + assert.True(t, d.created.Mounts[0].ReadOnly) + assert.Equal(t, reportsTarget, d.created.Mounts[1].Target) +} + +func TestKarateRunner_InfraErrors(t *testing.T) { + cases := map[string]*fakeDocker{ + "pull": {pullErr: errors.New("boom")}, + "create": {createErr: errors.New("boom")}, + "start": {startErr: errors.New("boom")}, + "wait": {waitErr: errors.New("boom")}, + } + for name, d := range cases { + t.Run(name, func(t *testing.T) { + r := NewKarateRunner(d, "", nil) + _, err := r.Run(context.Background(), cfgWithFeatures(t)) + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrTestFailed)) + }) + } +} + +func TestNewKarateRunner_DefaultImage(t *testing.T) { + r := NewKarateRunner(&fakeDocker{}, "", nil) + assert.Equal(t, DefaultLauncherImage, r.image) + + r2 := NewKarateRunner(&fakeDocker{}, "custom:1", nil) + assert.Equal(t, "custom:1", r2.image) +} diff --git a/internal/core/test/stablekarate/runner.go b/internal/core/test/stablekarate/runner.go new file mode 100644 index 0000000..cd16c41 --- /dev/null +++ b/internal/core/test/stablekarate/runner.go @@ -0,0 +1,231 @@ +// Package stablekarate reproduces the legacy DIA component tool's launch_tests +// step: it runs the test-launcher-back (Karate) STABLE image on the host +// network against the already-running app and mocks, mounting the feature files +// and writing the HTML report to the host, and can open that report in a browser. +package stablekarate + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "go.uber.org/zap" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +const ( + // defaultImage is gtool's own Karate test launcher image (see test-launcher/). + // Build it locally with `make test-launcher-image`; EnsureImage skips the + // pull when the image is already present. + defaultImage = "gtool/test-launcher-back:latest" + + pubsubEmulatorHost = "127.0.0.1:9085" + + featuresTarget = "/app/features" + reportsTarget = "/app/target/karate-reports" + + defaultFeaturesPath = "test/component/features" + defaultReportsPath = "test/component/reports" + + summaryFile = "karate-summary.html" +) + +// DockerClient is the subset of *docker.Client used by the runner. +type DockerClient interface { + EnsureImage(ctx context.Context, image string) error + CreateContainer(ctx context.Context, cfg *docker.ContainerConfig) (string, error) + StartContainer(ctx context.Context, id string) error + WaitForContainer(ctx context.Context, id string, condition container.WaitCondition) error + InspectContainer(ctx context.Context, id string) (*dockertypes.ContainerJSON, error) + GetContainerLogs(ctx context.Context, id string, tail int) (string, error) + RemoveContainer(ctx context.Context, id string, force bool) error +} + +// Options configures a Karate run. +type Options struct { + Image string // defaults to the STABLE test-launcher-back image + Tags string + UrlsToBlock string + FeaturesPath string // defaults to test/component/features + ReportsPath string // defaults to test/component/reports + Open bool // open the HTML report when the run finishes +} + +// Result is the outcome of a Karate run. +type Result struct { + Passed bool + ExitCode int + ReportPath string // path to the HTML summary, if generated + Duration time.Duration +} + +// Runner runs the Karate launcher container. +type Runner struct { + docker DockerClient + logger *zap.Logger + opener func(path string) error +} + +// New builds a Runner. +func New(d DockerClient, logger *zap.Logger) *Runner { + if logger == nil { + logger = zap.NewNop() + } + return &Runner{docker: d, logger: logger, opener: openInBrowser} +} + +// Run executes the Karate suite and returns the result. It assumes the mocks +// and app are already running (like the legacy "component e"). +func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) { + image := opts.Image + if image == "" { + image = defaultImage + } + featuresPath := orDefault(opts.FeaturesPath, defaultFeaturesPath) + reportsPath := orDefault(opts.ReportsPath, defaultReportsPath) + + featuresAbs, err := filepath.Abs(featuresPath) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "invalid features path") + } + if info, statErr := os.Stat(featuresAbs); statErr != nil || !info.IsDir() { + return nil, gtErrors.New(gtErrors.ErrTestFailed, + fmt.Sprintf("features directory not found: %s", featuresAbs)) + } + + reportsAbs, err := filepath.Abs(reportsPath) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "invalid reports path") + } + + if err := r.docker.EnsureImage(ctx, image); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to ensure test launcher image") + } + + // Reset the reports dir, mirroring the legacy "rm -rf && mkdir -p". The + // launcher runs as root, so a previous run leaves root-owned files our user + // can't delete; if removal fails, chown them back (via a root container) and + // retry, replacing the legacy "sudo rm -rf". + if err := os.RemoveAll(reportsAbs); err != nil { + r.chownToUser(ctx, image, reportsAbs) + if err := os.RemoveAll(reportsAbs); err != nil { + r.logger.Warn("could not clean reports dir", zap.String("path", reportsAbs), zap.Error(err)) + } + } + if err := os.MkdirAll(reportsAbs, 0o755); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to create reports directory") + } + + containerID, err := r.docker.CreateContainer(ctx, &docker.ContainerConfig{ + Image: image, + Name: fmt.Sprintf("gtool-karate-%d", time.Now().Unix()), + Env: []string{ + "PUBSUB_EMULATOR_HOST=" + pubsubEmulatorHost, + "TAGS=" + opts.Tags, + "URLS_TO_BLOCK=" + opts.UrlsToBlock, + }, + Mounts: []docker.Mount{ + {Type: "bind", Source: featuresAbs, Target: featuresTarget, ReadOnly: true}, + {Type: "bind", Source: reportsAbs, Target: reportsTarget}, + }, + NetworkMode: "host", + Init: true, + Labels: map[string]string{"managed-by": "gtool", "gtool-role": "karate"}, + }) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to create test container") + } + defer func() { + if rmErr := r.docker.RemoveContainer(context.Background(), containerID, true); rmErr != nil { + r.logger.Warn("failed to remove test container", zap.Error(rmErr)) + } + }() + + fmt.Printf("🥋 Running Karate tests...\n") + start := time.Now() + if err := r.docker.StartContainer(ctx, containerID); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed to start test container") + } + if err := r.docker.WaitForContainer(ctx, containerID, container.WaitConditionNotRunning); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrTestFailed, "failed while waiting for tests to finish") + } + duration := time.Since(start) + + exitCode := 0 + if inspect, err := r.docker.InspectContainer(ctx, containerID); err == nil && inspect.State != nil { + exitCode = inspect.State.ExitCode + } + if logs, err := r.docker.GetContainerLogs(ctx, containerID, 2000); err == nil { + fmt.Println(logs) + } + + result := &Result{Passed: exitCode == 0, ExitCode: exitCode, Duration: duration} + + // The launcher wrote the report as root; hand ownership back to the invoking + // user so it can be read/opened (e.g. by a sandboxed browser) and cleaned up. + r.chownToUser(ctx, image, reportsAbs) + + summary := filepath.Join(reportsAbs, summaryFile) + if _, statErr := os.Stat(summary); statErr == nil { + result.ReportPath = summary + fmt.Printf("📊 Report: %s\n", summary) + if opts.Open { + if err := r.opener(summary); err != nil { + r.logger.Warn("could not open report", zap.Error(err)) + } + } + } + + return result, nil +} + +// chownToUser hands ownership of the reports tree back to the invoking user by +// running chown inside a short-lived root container (the launcher writes the +// reports as root). Best-effort: failures are logged, not fatal. +func (r *Runner) chownToUser(ctx context.Context, image, reportsAbs string) { + uid, gid := os.Getuid(), os.Getgid() + if uid < 0 || gid < 0 { + return // not a Unix host + } + + id, err := r.docker.CreateContainer(ctx, &docker.ContainerConfig{ + Image: image, + Entrypoint: []string{"chown"}, + Cmd: []string{"-R", fmt.Sprintf("%d:%d", uid, gid), reportsTarget}, + Mounts: []docker.Mount{{Type: "bind", Source: reportsAbs, Target: reportsTarget}}, + Labels: map[string]string{"managed-by": "gtool", "gtool-role": "chown"}, + }) + if err != nil { + r.logger.Warn("could not create chown container", zap.Error(err)) + return + } + defer func() { _ = r.docker.RemoveContainer(context.Background(), id, true) }() + + if err := r.docker.StartContainer(ctx, id); err != nil { + r.logger.Warn("could not start chown container", zap.Error(err)) + return + } + if err := r.docker.WaitForContainer(ctx, id, container.WaitConditionNotRunning); err != nil { + r.logger.Warn("chown container did not finish cleanly", zap.Error(err)) + } +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +// openInBrowser opens a file/URL with the platform's default handler. +func openInBrowser(path string) error { + cmd := exec.Command("xdg-open", path) + return cmd.Start() +} diff --git a/internal/core/test/stablekarate/runner_test.go b/internal/core/test/stablekarate/runner_test.go new file mode 100644 index 0000000..d36acf3 --- /dev/null +++ b/internal/core/test/stablekarate/runner_test.go @@ -0,0 +1,177 @@ +package stablekarate + +import ( + "context" + "os" + "path/filepath" + "testing" + + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/oswaldom-code/gtool/internal/infra/docker" +) + +type fakeDocker struct { + created []*docker.ContainerConfig + exitCode int + removed bool + reportsSrc string +} + +func (f *fakeDocker) EnsureImage(context.Context, string) error { return nil } + +func (f *fakeDocker) CreateContainer(_ context.Context, cfg *docker.ContainerConfig) (string, error) { + f.created = append(f.created, cfg) + for _, m := range cfg.Mounts { + if m.Target == reportsTarget { + f.reportsSrc = m.Source + } + } + return "cid", nil +} + +// karateCfg returns the launcher container config (the one on the host network), +// as opposed to the helper chown container. +func (f *fakeDocker) karateCfg() *docker.ContainerConfig { + for _, c := range f.created { + if c.NetworkMode == "host" { + return c + } + } + return nil +} + +func (f *fakeDocker) StartContainer(context.Context, string) error { return nil } + +func (f *fakeDocker) WaitForContainer(context.Context, string, container.WaitCondition) error { + // Simulate the launcher writing the HTML report. + if f.reportsSrc != "" { + _ = os.WriteFile(filepath.Join(f.reportsSrc, summaryFile), []byte(""), 0o644) + } + return nil +} + +func (f *fakeDocker) InspectContainer(context.Context, string) (*dockertypes.ContainerJSON, error) { + return &dockertypes.ContainerJSON{ + ContainerJSONBase: &dockertypes.ContainerJSONBase{ + State: &dockertypes.ContainerState{ExitCode: f.exitCode}, + }, + }, nil +} + +func (f *fakeDocker) GetContainerLogs(context.Context, string, int) (string, error) { + return "karate logs", nil +} + +func (f *fakeDocker) RemoveContainer(context.Context, string, bool) error { + f.removed = true + return nil +} + +func newRunner(t *testing.T, fd *fakeDocker) (*Runner, *[]string) { + t.Helper() + opened := &[]string{} + r := New(fd, zap.NewNop()) + r.opener = func(path string) error { + *opened = append(*opened, path) + return nil + } + return r, opened +} + +func dirs(t *testing.T) (features, reports string) { + t.Helper() + base := t.TempDir() + features = filepath.Join(base, "features") + reports = filepath.Join(base, "reports") + require.NoError(t, os.Mkdir(features, 0o755)) + return features, reports +} + +func TestRunPassesAndOpensReport(t *testing.T) { + features, reports := dirs(t) + fd := &fakeDocker{exitCode: 0} + r, opened := newRunner(t, fd) + + res, err := r.Run(context.Background(), Options{ + FeaturesPath: features, + ReportsPath: reports, + Tags: "@smoke", + UrlsToBlock: "a.com,b.com", + Open: true, + }) + require.NoError(t, err) + assert.True(t, res.Passed) + assert.Equal(t, 0, res.ExitCode) + assert.True(t, fd.removed, "container should be removed") + + // Report generated and opened. + require.NotEmpty(t, res.ReportPath) + require.Len(t, *opened, 1) + assert.Equal(t, res.ReportPath, (*opened)[0]) + + // Container contract. + cc := fd.karateCfg() + require.NotNil(t, cc) + assert.Equal(t, defaultImage, cc.Image) + assert.Equal(t, "host", cc.NetworkMode) + assert.True(t, cc.Init) + assert.Contains(t, cc.Env, "PUBSUB_EMULATOR_HOST="+pubsubEmulatorHost) + assert.Contains(t, cc.Env, "TAGS=@smoke") + assert.Contains(t, cc.Env, "URLS_TO_BLOCK=a.com,b.com") + + var featTarget, repTarget string + for _, m := range cc.Mounts { + switch m.Target { + case featuresTarget: + featTarget = m.Source + assert.True(t, m.ReadOnly) + case reportsTarget: + repTarget = m.Source + } + } + assert.NotEmpty(t, featTarget) + assert.NotEmpty(t, repTarget) +} + +func TestRunDoesNotOpenWhenDisabled(t *testing.T) { + features, reports := dirs(t) + fd := &fakeDocker{exitCode: 0} + r, opened := newRunner(t, fd) + + _, err := r.Run(context.Background(), Options{FeaturesPath: features, ReportsPath: reports, Open: false}) + require.NoError(t, err) + assert.Empty(t, *opened) +} + +func TestRunFailsOnNonZeroExit(t *testing.T) { + features, reports := dirs(t) + fd := &fakeDocker{exitCode: 1} + r, _ := newRunner(t, fd) + + res, err := r.Run(context.Background(), Options{FeaturesPath: features, ReportsPath: reports}) + require.NoError(t, err) + assert.False(t, res.Passed) + assert.Equal(t, 1, res.ExitCode) +} + +func TestRunErrorsWhenFeaturesMissing(t *testing.T) { + fd := &fakeDocker{} + r, _ := newRunner(t, fd) + _, err := r.Run(context.Background(), Options{FeaturesPath: "/does/not/exist", ReportsPath: t.TempDir()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "features directory not found") +} + +func TestRunImageOverride(t *testing.T) { + features, reports := dirs(t) + fd := &fakeDocker{} + r, _ := newRunner(t, fd) + _, err := r.Run(context.Background(), Options{FeaturesPath: features, ReportsPath: reports, Image: "custom:tag"}) + require.NoError(t, err) + assert.Equal(t, "custom:tag", fd.karateCfg().Image) +} diff --git a/internal/infra/docker/client.go b/internal/infra/docker/client.go new file mode 100644 index 0000000..2cbf15a --- /dev/null +++ b/internal/infra/docker/client.go @@ -0,0 +1,505 @@ +package docker + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + "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/mount" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/docker/go-connections/nat" + "go.uber.org/zap" + + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +type Client struct { + cli *client.Client + logger *zap.Logger +} + +type ContainerConfig struct { + Image string + Name string + Entrypoint []string + Cmd []string + Env []string + PortBindings map[string]string + Mounts []Mount + NetworkMode string + AutoRemove bool + Init bool + User string + Labels map[string]string +} + +type Mount struct { + Type string + Source string + Target string + ReadOnly bool +} + +type ExecConfig struct { + Cmd []string + AttachStdout bool + AttachStderr bool + WorkingDir string + Env []string +} + +func NewClient(logger *zap.Logger) (*Client, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create Docker client") + } + + if logger == nil { + logger = zap.NewNop() + } + + return &Client{ + cli: cli, + logger: logger, + }, nil +} + +func (c *Client) Close() error { + return c.cli.Close() +} + +func (c *Client) PullImage(ctx context.Context, imageName string) error { + c.logger.Info("pulling Docker image", zap.String("image", imageName)) + + reader, err := c.cli.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, fmt.Sprintf("failed to pull image %s", imageName)) + } + defer reader.Close() + + _, err = io.Copy(io.Discard, reader) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to read pull response") + } + + c.logger.Info("successfully pulled image", zap.String("image", imageName)) + return nil +} + +// ImageExists reports whether an image is already present in the local Docker +// image store, so callers can avoid a registry pull for local-only images +// (e.g. private STABLE images or freshly built ones). +func (c *Client) ImageExists(ctx context.Context, imageName string) (bool, error) { + _, _, err := c.cli.ImageInspectWithRaw(ctx, imageName) + if err != nil { + if client.IsErrNotFound(err) { + return false, nil + } + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, + fmt.Sprintf("failed to inspect image %s", imageName)) + } + return true, nil +} + +// EnsureImage makes an image available, preferring a local copy: if the image +// already exists locally it skips the pull, otherwise it pulls from the registry. +func (c *Client) EnsureImage(ctx context.Context, imageName string) error { + exists, err := c.ImageExists(ctx, imageName) + if err != nil { + return err + } + if exists { + c.logger.Info("image already present locally, skipping pull", zap.String("image", imageName)) + return nil + } + return c.PullImage(ctx, imageName) +} + +func (c *Client) CreateContainer(ctx context.Context, config *ContainerConfig) (string, error) { + c.logger.Info("creating container", + zap.String("image", config.Image), + zap.String("name", config.Name)) + + // Build port bindings + portBindings := nat.PortMap{} + exposedPorts := nat.PortSet{} + for containerPort, hostPort := range config.PortBindings { + port, err := nat.NewPort("tcp", containerPort) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "invalid port specification") + } + exposedPorts[port] = struct{}{} + portBindings[port] = []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: hostPort, + }, + } + } + + // Build mounts + var mounts []mount.Mount + for _, m := range config.Mounts { + mounts = append(mounts, mount.Mount{ + Type: mount.Type(m.Type), + Source: m.Source, + Target: m.Target, + ReadOnly: m.ReadOnly, + }) + } + + // Create container + containerConfig := &container.Config{ + Image: config.Image, + Entrypoint: config.Entrypoint, + Cmd: config.Cmd, + Env: config.Env, + ExposedPorts: exposedPorts, + User: config.User, + Labels: config.Labels, + } + + hostConfig := &container.HostConfig{ + PortBindings: portBindings, + Mounts: mounts, + AutoRemove: config.AutoRemove, + } + + if config.NetworkMode != "" { + hostConfig.NetworkMode = container.NetworkMode(config.NetworkMode) + } + + if config.Init { + initFlag := true + hostConfig.Init = &initFlag + } + + resp, err := c.cli.ContainerCreate( + ctx, + containerConfig, + hostConfig, + &network.NetworkingConfig{}, + nil, + config.Name, + ) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create container") + } + + c.logger.Info("container created", + zap.String("containerID", resp.ID), + zap.String("name", config.Name)) + + return resp.ID, nil +} + +func (c *Client) StartContainer(ctx context.Context, containerID string) error { + c.logger.Info("starting container", zap.String("containerID", containerID)) + + if err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start container") + } + + c.logger.Info("container started", zap.String("containerID", containerID)) + return nil +} + +func (c *Client) StopContainer(ctx context.Context, containerID string, timeout *int) error { + c.logger.Info("stopping container", zap.String("containerID", containerID)) + + stopTimeout := 10 + if timeout != nil { + stopTimeout = *timeout + } + + if err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{ + Timeout: &stopTimeout, + }); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to stop container") + } + + c.logger.Info("container stopped", zap.String("containerID", containerID)) + return nil +} + +func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error { + c.logger.Info("removing container", + zap.String("containerID", containerID), + zap.Bool("force", force)) + + err := c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{ + Force: force, + RemoveVolumes: true, + }) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove container") + } + + c.logger.Info("container removed", zap.String("containerID", containerID)) + return nil +} + +// RemoveContainerByName force-removes any container with the given exact name +// (in any state). It is a no-op when no such container exists, mirroring the +// legacy "remove_container_if_exists" behaviour. Returns true if one was removed. +func (c *Client) RemoveContainerByName(ctx context.Context, name string) (bool, error) { + args := filters.NewArgs() + args.Add("name", name) + + containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: args}) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list containers") + } + + removed := false + for _, ct := range containers { + // Docker's name filter matches substrings; require an exact match. + matched := false + for _, n := range ct.Names { + if strings.TrimPrefix(n, "/") == name { + matched = true + break + } + } + if !matched { + continue + } + if err := c.RemoveContainer(ctx, ct.ID, true); err != nil { + return removed, err + } + removed = true + } + + return removed, nil +} + +func (c *Client) GetContainerLogs(ctx context.Context, containerID string, tail int) (string, error) { + options := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: fmt.Sprintf("%d", tail), + } + + reader, err := c.cli.ContainerLogs(ctx, containerID, options) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + defer reader.Close() + + // Docker logs come with 8-byte headers, use stdcopy to demultiplex + var stdout, stderr bytes.Buffer + written, err := stdcopy.StdCopy(&stdout, &stderr, reader) + + c.logger.Debug("log demultiplex result", + zap.Int64("bytes_written", written), + zap.Int("stdout_len", stdout.Len()), + zap.Int("stderr_len", stderr.Len()), + zap.Error(err)) + + // If stdcopy didn't read anything or failed, fall back to raw read + if err != nil || written == 0 { + if err != nil { + c.logger.Warn("failed to demultiplex logs, reading raw", zap.Error(err)) + } else { + c.logger.Debug("stdcopy read 0 bytes, trying raw read") + } + + // Reopen reader + reader2, err2 := c.cli.ContainerLogs(ctx, containerID, options) + if err2 != nil { + return "", gtErrors.Wrap(err2, gtErrors.ErrDockerFailed, "failed to get container logs (retry)") + } + defer reader2.Close() + + logs, err3 := io.ReadAll(reader2) + if err3 != nil { + return "", gtErrors.Wrap(err3, gtErrors.ErrDockerFailed, "failed to read container logs") + } + c.logger.Debug("raw read result", zap.Int("bytes", len(logs))) + return string(logs), nil + } + + // Combine stdout and stderr + combined := stdout.String() + stderr.String() + c.logger.Debug("combined logs length", zap.Int("length", len(combined))) + return combined, nil +} + +func (c *Client) ListContainersByLabels(ctx context.Context, labels map[string]string) ([]types.Container, error) { + // Build filter string + filters := filters.NewArgs() + for key, value := range labels { + filters.Add("label", fmt.Sprintf("%s=%s", key, value)) + } + + c.logger.Debug("listing containers by labels", zap.Any("labels", labels)) + + containers, err := c.cli.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters, + }) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list containers") + } + + c.logger.Debug("found containers", zap.Int("count", len(containers))) + return containers, nil +} + +func (c *Client) ExecInContainer(ctx context.Context, containerID string, config *ExecConfig) (string, error) { + c.logger.Info("executing command in container", + zap.String("containerID", containerID), + zap.Strings("cmd", config.Cmd)) + + execConfig := types.ExecConfig{ + AttachStdout: config.AttachStdout, + AttachStderr: config.AttachStderr, + Cmd: config.Cmd, + WorkingDir: config.WorkingDir, + Env: config.Env, + } + + execID, err := c.cli.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create exec instance") + } + + resp, err := c.cli.ContainerExecAttach(ctx, execID.ID, types.ExecStartCheck{}) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to attach to exec instance") + } + defer resp.Close() + + output, err := io.ReadAll(resp.Reader) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to read exec output") + } + + // Check exec exit code + inspectResp, err := c.cli.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return string(output), gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to inspect exec instance") + } + + if inspectResp.ExitCode != 0 { + return string(output), gtErrors.New(gtErrors.ErrDockerFailed, + fmt.Sprintf("command exited with code %d: %s", inspectResp.ExitCode, string(output))) + } + + return string(output), nil +} + +func (c *Client) WaitForContainer(ctx context.Context, containerID string, condition container.WaitCondition) error { + c.logger.Info("waiting for container", + zap.String("containerID", containerID), + zap.String("condition", string(condition))) + + statusCh, errCh := c.cli.ContainerWait(ctx, containerID, condition) + + select { + case err := <-errCh: + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "error waiting for container") + } + case <-statusCh: + c.logger.Info("container reached desired state", zap.String("containerID", containerID)) + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrDockerFailed, "context cancelled while waiting for container") + } + + return nil +} + +func (c *Client) InspectContainer(ctx context.Context, containerID string) (*types.ContainerJSON, error) { + inspect, err := c.cli.ContainerInspect(ctx, containerID) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to inspect container") + } + return &inspect, nil +} + +func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) { + inspect, err := c.InspectContainer(ctx, containerID) + if err != nil { + return false, err + } + return inspect.State.Running, nil +} + +func (c *Client) Ping(ctx context.Context) error { + _, err := c.cli.Ping(ctx) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to ping Docker daemon") + } + return nil +} + +func (c *Client) CopyToContainer(ctx context.Context, containerID, targetPath string, content io.Reader) error { + c.logger.Info("copying to container", + zap.String("containerID", containerID), + zap.String("targetPath", targetPath)) + + err := c.cli.CopyToContainer(ctx, containerID, targetPath, content, types.CopyToContainerOptions{}) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to copy to container") + } + + return nil +} + +func (c *Client) WaitForHealthy(ctx context.Context, containerID string, interval time.Duration, retries int) error { + c.logger.Info("waiting for container to be healthy", + zap.String("containerID", containerID), + zap.Duration("interval", interval), + zap.Int("retries", retries)) + + for i := 0; i < retries; i++ { + inspect, err := c.InspectContainer(ctx, containerID) + if err != nil { + return err + } + + if inspect.State.Running { + // If no health check is defined, just check if running + if inspect.State.Health == nil { + c.logger.Info("container is running (no health check defined)", + zap.String("containerID", containerID)) + return nil + } + + // Check health status + if inspect.State.Health.Status == "healthy" { + c.logger.Info("container is healthy", zap.String("containerID", containerID)) + return nil + } + + c.logger.Debug("container not healthy yet", + zap.String("containerID", containerID), + zap.String("status", inspect.State.Health.Status), + zap.Int("attempt", i+1)) + } else { + c.logger.Debug("container not running", + zap.String("containerID", containerID), + zap.Int("attempt", i+1)) + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrDockerFailed, "context cancelled while waiting for healthy state") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrDockerFailed, + fmt.Sprintf("container did not become healthy after %d attempts", retries)) +} diff --git a/internal/infra/process/manager.go b/internal/infra/process/manager.go new file mode 100644 index 0000000..8541f43 --- /dev/null +++ b/internal/infra/process/manager.go @@ -0,0 +1,256 @@ +// Package process provides native OS process management: starting processes, +// capturing their output, and stopping them gracefully. It is the foundation +// for the local-execution app launchers (Phase 3). +package process + +import ( + "bytes" + "context" + "os" + "os/exec" + "sync" + "syscall" + "time" + + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +// StartOptions describes a process to launch. +type StartOptions struct { + Command string + Args []string + Env map[string]string + WorkDir string +} + +// Process represents a managed OS process and its captured output. +type Process struct { + PID int + StartTime time.Time + + cmd *exec.Cmd + output *lineBuffer + doneCh chan struct{} + + mu sync.Mutex + done bool + exitErr error +} + +// IsRunning reports whether the process has not yet exited. +func (p *Process) IsRunning() bool { + p.mu.Lock() + defer p.mu.Unlock() + return !p.done +} + +// ExitError returns the error from the process exit, if any. It is nil while +// the process is still running or if it exited successfully. +func (p *Process) ExitError() error { + p.mu.Lock() + defer p.mu.Unlock() + return p.exitErr +} + +// Output returns the lines captured from stdout and stderr so far. +func (p *Process) Output() []string { + return p.output.Lines() +} + +func (p *Process) markDone(err error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.done { + return + } + p.done = true + p.exitErr = err + close(p.doneCh) +} + +// Manager starts and tracks native OS processes. +type Manager struct { + logger *zap.Logger + + mu sync.RWMutex + processes map[int]*Process +} + +// NewManager creates a new process Manager. +func NewManager(logger *zap.Logger) *Manager { + if logger == nil { + logger = zap.NewNop() + } + return &Manager{ + logger: logger, + processes: make(map[int]*Process), + } +} + +// Start launches a process and begins capturing its output. The returned +// Process is tracked by the Manager and can be referenced later by its PID. +func (m *Manager) Start(ctx context.Context, opts StartOptions) (*Process, error) { + if opts.Command == "" { + return nil, gtErrors.New(gtErrors.ErrInvalidArgument, "command is required") + } + + cmd := exec.CommandContext(ctx, opts.Command, opts.Args...) + cmd.Dir = opts.WorkDir + cmd.Env = mergeEnv(os.Environ(), opts.Env) + + out := &lineBuffer{} + cmd.Stdout = out + cmd.Stderr = out + + if err := cmd.Start(); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrProcessFailed, "failed to start process") + } + + p := &Process{ + PID: cmd.Process.Pid, + StartTime: time.Now(), + cmd: cmd, + output: out, + doneCh: make(chan struct{}), + } + + m.logger.Info("process started", + zap.String("command", opts.Command), + zap.Int("pid", p.PID)) + + // Reap the process in the background and record its exit state. + go func() { + err := cmd.Wait() + p.markDone(err) + m.logger.Info("process exited", zap.Int("pid", p.PID), zap.Error(err)) + }() + + m.mu.Lock() + m.processes[p.PID] = p + m.mu.Unlock() + + return p, nil +} + +// Stop gracefully terminates a process: it sends SIGTERM and, if the process +// has not exited within timeout, escalates to SIGKILL. +func (m *Manager) Stop(pid int, timeout time.Duration) error { + p := m.get(pid) + if p == nil { + return gtErrors.New(gtErrors.ErrProcessFailed, "process not found") + } + + if !p.IsRunning() { + return nil + } + + if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil { + // Process may have just exited; fall through to wait/kill. + m.logger.Debug("failed to send SIGTERM", zap.Int("pid", pid), zap.Error(err)) + } + + select { + case <-p.doneCh: + return nil + case <-time.After(timeout): + m.logger.Warn("process did not stop, sending SIGKILL", zap.Int("pid", pid)) + if err := p.cmd.Process.Kill(); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrProcessFailed, "failed to kill process") + } + <-p.doneCh + return nil + } +} + +// Kill terminates a process immediately with SIGKILL. +func (m *Manager) Kill(pid int) error { + p := m.get(pid) + if p == nil { + return gtErrors.New(gtErrors.ErrProcessFailed, "process not found") + } + + if !p.IsRunning() { + return nil + } + + if err := p.cmd.Process.Kill(); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrProcessFailed, "failed to kill process") + } + <-p.doneCh + return nil +} + +// IsRunning reports whether the tracked process with the given PID is running. +func (m *Manager) IsRunning(pid int) bool { + p := m.get(pid) + return p != nil && p.IsRunning() +} + +// GetOutput returns the captured output lines for the tracked process. +func (m *Manager) GetOutput(pid int) ([]string, error) { + p := m.get(pid) + if p == nil { + return nil, gtErrors.New(gtErrors.ErrProcessFailed, "process not found") + } + return p.Output(), nil +} + +func (m *Manager) get(pid int) *Process { + m.mu.RLock() + defer m.mu.RUnlock() + return m.processes[pid] +} + +// mergeEnv returns base with the key/value pairs from extra applied as +// KEY=VALUE entries (overrides are appended; later entries win in exec). +func mergeEnv(base []string, extra map[string]string) []string { + if len(extra) == 0 { + return base + } + merged := make([]string, 0, len(base)+len(extra)) + merged = append(merged, base...) + for k, v := range extra { + merged = append(merged, k+"="+v) + } + return merged +} + +// lineBuffer is a thread-safe io.Writer that accumulates written bytes and +// exposes them split into lines. +type lineBuffer struct { + mu sync.Mutex + lines []string + partial []byte +} + +func (b *lineBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + b.partial = append(b.partial, p...) + for { + i := bytes.IndexByte(b.partial, '\n') + if i < 0 { + break + } + line := string(bytes.TrimSuffix(b.partial[:i], []byte("\r"))) + b.lines = append(b.lines, line) + b.partial = b.partial[i+1:] + } + return len(p), nil +} + +// Lines returns a copy of the captured lines, including any trailing +// partial line that was not newline-terminated. +func (b *lineBuffer) Lines() []string { + b.mu.Lock() + defer b.mu.Unlock() + + out := make([]string, 0, len(b.lines)+1) + out = append(out, b.lines...) + if len(b.partial) > 0 { + out = append(out, string(b.partial)) + } + return out +} diff --git a/internal/infra/process/manager_test.go b/internal/infra/process/manager_test.go new file mode 100644 index 0000000..b02147f --- /dev/null +++ b/internal/infra/process/manager_test.go @@ -0,0 +1,205 @@ +package process + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" +) + +func waitNotRunning(t *testing.T, m *Manager, pid int) { + t.Helper() + require.Eventually(t, func() bool { return !m.IsRunning(pid) }, 2*time.Second, 5*time.Millisecond) +} + +func TestManager_Start_CapturesStdoutAndStderr(t *testing.T) { + m := NewManager(nil) + + p, err := m.Start(context.Background(), StartOptions{ + Command: "sh", + Args: []string{"-c", "echo out; echo err >&2"}, + }) + require.NoError(t, err) + assert.Greater(t, p.PID, 0) + + waitNotRunning(t, m, p.PID) + + out, err := m.GetOutput(p.PID) + require.NoError(t, err) + assert.Contains(t, out, "out") + assert.Contains(t, out, "err") +} + +func TestManager_Start_EmptyCommand(t *testing.T) { + m := NewManager(nil) + + _, err := m.Start(context.Background(), StartOptions{}) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrInvalidArgument)) +} + +func TestManager_Start_InvalidCommand(t *testing.T) { + m := NewManager(nil) + + _, err := m.Start(context.Background(), StartOptions{Command: "this-binary-does-not-exist-gtool"}) + + require.Error(t, err) + assert.True(t, gtErrors.Is(err, gtErrors.ErrProcessFailed)) +} + +func TestManager_Env(t *testing.T) { + m := NewManager(nil) + + p, err := m.Start(context.Background(), StartOptions{ + Command: "sh", + Args: []string{"-c", "echo $GTOOL_TEST_VAR"}, + Env: map[string]string{"GTOOL_TEST_VAR": "hello-env"}, + }) + require.NoError(t, err) + waitNotRunning(t, m, p.PID) + + out, err := m.GetOutput(p.PID) + require.NoError(t, err) + assert.Contains(t, out, "hello-env") +} + +func TestManager_WorkDir(t *testing.T) { + dir := t.TempDir() + m := NewManager(nil) + + p, err := m.Start(context.Background(), StartOptions{ + Command: "pwd", + WorkDir: dir, + }) + require.NoError(t, err) + waitNotRunning(t, m, p.PID) + + out, err := m.GetOutput(p.PID) + require.NoError(t, err) + require.NotEmpty(t, out) + // macOS/Linux may resolve symlinks; assert the basename is present. + assert.Contains(t, out[0], filepathBase(dir)) +} + +func TestManager_IsRunning(t *testing.T) { + m := NewManager(nil) + + p, err := m.Start(context.Background(), StartOptions{ + Command: "sleep", + Args: []string{"5"}, + }) + require.NoError(t, err) + + assert.True(t, m.IsRunning(p.PID)) + + require.NoError(t, m.Kill(p.PID)) + assert.False(t, m.IsRunning(p.PID)) +} + +func TestManager_Stop_Graceful(t *testing.T) { + m := NewManager(nil) + + p, err := m.Start(context.Background(), StartOptions{ + Command: "sleep", + Args: []string{"30"}, + }) + require.NoError(t, err) + require.True(t, m.IsRunning(p.PID)) + + start := time.Now() + require.NoError(t, m.Stop(p.PID, 2*time.Second)) + assert.False(t, m.IsRunning(p.PID)) + assert.Less(t, time.Since(start), 2*time.Second, "SIGTERM should stop sleep promptly") +} + +func TestManager_Stop_EscalatesToKill(t *testing.T) { + m := NewManager(nil) + + // Trap SIGTERM so only SIGKILL can stop it; Stop must escalate after timeout. + p, err := m.Start(context.Background(), StartOptions{ + Command: "sh", + Args: []string{"-c", "trap '' TERM; sleep 30"}, + }) + require.NoError(t, err) + require.True(t, m.IsRunning(p.PID)) + + require.NoError(t, m.Stop(p.PID, 200*time.Millisecond)) + assert.False(t, m.IsRunning(p.PID)) +} + +func TestManager_Stop_AlreadyExited(t *testing.T) { + m := NewManager(nil) + + p, err := m.Start(context.Background(), StartOptions{Command: "true"}) + require.NoError(t, err) + waitNotRunning(t, m, p.PID) + + // Stopping an already-exited process is a no-op. + require.NoError(t, m.Stop(p.PID, time.Second)) +} + +func TestManager_ExitError(t *testing.T) { + m := NewManager(nil) + + p, err := m.Start(context.Background(), StartOptions{Command: "false"}) + require.NoError(t, err) + waitNotRunning(t, m, p.PID) + + assert.Error(t, p.ExitError(), "non-zero exit should be recorded") +} + +func TestManager_UnknownPID(t *testing.T) { + m := NewManager(nil) + + assert.False(t, m.IsRunning(99999999)) + + _, err := m.GetOutput(99999999) + require.Error(t, err) + + require.Error(t, m.Stop(99999999, time.Second)) + require.Error(t, m.Kill(99999999)) +} + +func TestLineBuffer_PartialLine(t *testing.T) { + b := &lineBuffer{} + + _, _ = b.Write([]byte("complete\npar")) + lines := b.Lines() + + require.Len(t, lines, 2) + assert.Equal(t, "complete", lines[0]) + assert.Equal(t, "par", lines[1], "trailing partial line should be included") +} + +func TestLineBuffer_ConcurrentWrites(t *testing.T) { + b := &lineBuffer{} + var wg sync.WaitGroup + + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = b.Write([]byte("line\n")) + }() + } + wg.Wait() + + assert.Len(t, b.Lines(), 50) +} + +// filepathBase returns the last path element, avoiding an extra import in the +// test for a single use. +func filepathBase(p string) string { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' { + return p[i+1:] + } + } + return p +} diff --git a/internal/plugin/interface.go b/internal/plugin/interface.go new file mode 100644 index 0000000..d7a3f30 --- /dev/null +++ b/internal/plugin/interface.go @@ -0,0 +1,85 @@ +package plugin + +import ( + "context" + "time" +) + +type ServicePlugin interface { + Name() string + Launch(ctx context.Context, config map[string]interface{}) error + IsReady(ctx context.Context) (bool, error) + Stop(ctx context.Context) error + GetConnectionInfo() (*ConnectionInfo, error) + GetLogs(ctx context.Context, opts *LogOptions) ([]string, error) +} + +type AppLauncher interface { + Technology() string + Launch(ctx context.Context, config *AppConfig) error + IsReady(ctx context.Context) (bool, error) + Stop(ctx context.Context) error + Restart(ctx context.Context) error + GetPID() (int, error) +} + +type TestExecutor interface { + Framework() string + Execute(ctx context.Context, config *TestConfig) (*TestResult, error) + Cancel(ctx context.Context) error + GetProgress(ctx context.Context) (*TestProgress, error) +} + +type ConnectionInfo struct { + Host string + Port int + Protocol string + Metadata map[string]string +} + +type LogOptions struct { + Since time.Time + Until time.Time + Tail int + Follow bool +} + +type AppConfig struct { + BinaryName string + BinaryPath string + DockerImage string + Port int + Environment map[string]string + WorkDir string +} + +type TestConfig struct { + Tags []string + FeaturesPath string + ReportsPath string + Parallel bool + Environment map[string]string +} + +type TestResult struct { + Total int + Passed int + Failed int + Skipped int + Duration time.Duration + ReportURL string + Failures []TestFailure +} + +type TestFailure struct { + Name string + Feature string + Message string + Stack string +} + +type TestProgress struct { + Current int + Total int + Running string +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go new file mode 100644 index 0000000..28ceac2 --- /dev/null +++ b/internal/plugin/registry.go @@ -0,0 +1,107 @@ +package plugin + +import ( + "fmt" + "sync" +) + +type Registry struct { + services map[string]ServicePlugin + launchers map[string]AppLauncher + executors map[string]TestExecutor + mu sync.RWMutex +} + +func NewRegistry() *Registry { + return &Registry{ + services: make(map[string]ServicePlugin), + launchers: make(map[string]AppLauncher), + executors: make(map[string]TestExecutor), + } +} + +func (r *Registry) RegisterService(plugin ServicePlugin) error { + r.mu.Lock() + defer r.mu.Unlock() + + name := plugin.Name() + if _, exists := r.services[name]; exists { + return fmt.Errorf("service plugin %s already registered", name) + } + + r.services[name] = plugin + return nil +} + +func (r *Registry) GetService(name string) (ServicePlugin, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + plugin, exists := r.services[name] + if !exists { + return nil, fmt.Errorf("service plugin %s not found", name) + } + + return plugin, nil +} + +func (r *Registry) ListServices() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.services)) + for name := range r.services { + names = append(names, name) + } + return names +} + +func (r *Registry) RegisterLauncher(plugin AppLauncher) error { + r.mu.Lock() + defer r.mu.Unlock() + + tech := plugin.Technology() + if _, exists := r.launchers[tech]; exists { + return fmt.Errorf("app launcher %s already registered", tech) + } + + r.launchers[tech] = plugin + return nil +} + +func (r *Registry) GetLauncher(technology string) (AppLauncher, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + plugin, exists := r.launchers[technology] + if !exists { + return nil, fmt.Errorf("app launcher %s not found", technology) + } + + return plugin, nil +} + +func (r *Registry) RegisterExecutor(plugin TestExecutor) error { + r.mu.Lock() + defer r.mu.Unlock() + + framework := plugin.Framework() + if _, exists := r.executors[framework]; exists { + return fmt.Errorf("test executor %s already registered", framework) + } + + r.executors[framework] = plugin + return nil +} + +func (r *Registry) GetExecutor(framework string) (TestExecutor, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + plugin, exists := r.executors[framework] + if !exists { + return nil, fmt.Errorf("test executor %s not found", framework) + } + + return plugin, nil +} diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go new file mode 100644 index 0000000..628e2b8 --- /dev/null +++ b/internal/plugin/registry_test.go @@ -0,0 +1,331 @@ +package plugin + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockServicePlugin struct { + name string +} + +func (m *mockServicePlugin) Name() string { return m.name } +func (m *mockServicePlugin) Launch(ctx context.Context, config map[string]interface{}) error { + return nil +} +func (m *mockServicePlugin) IsReady(ctx context.Context) (bool, error) { return true, nil } +func (m *mockServicePlugin) Stop(ctx context.Context) error { return nil } +func (m *mockServicePlugin) GetConnectionInfo() (*ConnectionInfo, error) { + return &ConnectionInfo{Host: "localhost", Port: 8080}, nil +} +func (m *mockServicePlugin) GetLogs(ctx context.Context, opts *LogOptions) ([]string, error) { + return []string{"log1", "log2"}, nil +} + +type mockAppLauncher struct { + tech string +} + +func (m *mockAppLauncher) Technology() string { return m.tech } +func (m *mockAppLauncher) Launch(ctx context.Context, config *AppConfig) error { return nil } +func (m *mockAppLauncher) IsReady(ctx context.Context) (bool, error) { return true, nil } +func (m *mockAppLauncher) Stop(ctx context.Context) error { return nil } +func (m *mockAppLauncher) Restart(ctx context.Context) error { return nil } +func (m *mockAppLauncher) GetPID() (int, error) { return 1234, nil } + +type mockTestExecutor struct { + framework string +} + +func (m *mockTestExecutor) Framework() string { return m.framework } +func (m *mockTestExecutor) Execute(ctx context.Context, config *TestConfig) (*TestResult, error) { + return &TestResult{Total: 10, Passed: 10}, nil +} +func (m *mockTestExecutor) Cancel(ctx context.Context) error { return nil } +func (m *mockTestExecutor) GetProgress(ctx context.Context) (*TestProgress, error) { + return &TestProgress{Current: 5, Total: 10}, nil +} + +func TestNewRegistry(t *testing.T) { + registry := NewRegistry() + assert.NotNil(t, registry) + assert.NotNil(t, registry.services) + assert.NotNil(t, registry.launchers) + assert.NotNil(t, registry.executors) +} + +func TestRegistry_RegisterService(t *testing.T) { + registry := NewRegistry() + + t.Run("register new service", func(t *testing.T) { + mock := &mockServicePlugin{name: "couchbase"} + err := registry.RegisterService(mock) + assert.NoError(t, err) + }) + + t.Run("register duplicate service", func(t *testing.T) { + mock := &mockServicePlugin{name: "couchbase"} + err := registry.RegisterService(mock) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + }) + + t.Run("register multiple services", func(t *testing.T) { + mocks := []*mockServicePlugin{ + {name: "kafka"}, + {name: "postgresql"}, + {name: "pubsub"}, + } + + for _, mock := range mocks { + err := registry.RegisterService(mock) + assert.NoError(t, err) + } + }) +} + +func TestRegistry_GetService(t *testing.T) { + registry := NewRegistry() + mock := &mockServicePlugin{name: "mountebank"} + registry.RegisterService(mock) + + t.Run("get existing service", func(t *testing.T) { + service, err := registry.GetService("mountebank") + assert.NoError(t, err) + assert.NotNil(t, service) + assert.Equal(t, "mountebank", service.Name()) + }) + + t.Run("get non-existent service", func(t *testing.T) { + service, err := registry.GetService("nonexistent") + assert.Error(t, err) + assert.Nil(t, service) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestRegistry_ListServices(t *testing.T) { + registry := NewRegistry() + + t.Run("list empty registry", func(t *testing.T) { + services := registry.ListServices() + assert.NotNil(t, services) + assert.Empty(t, services) + }) + + t.Run("list multiple services", func(t *testing.T) { + mocks := []*mockServicePlugin{ + {name: "couchbase"}, + {name: "kafka"}, + {name: "postgresql"}, + } + + for _, mock := range mocks { + registry.RegisterService(mock) + } + + services := registry.ListServices() + assert.Len(t, services, 3) + assert.Contains(t, services, "couchbase") + assert.Contains(t, services, "kafka") + assert.Contains(t, services, "postgresql") + }) +} + +func TestRegistry_RegisterLauncher(t *testing.T) { + registry := NewRegistry() + + t.Run("register new launcher", func(t *testing.T) { + mock := &mockAppLauncher{tech: "golang"} + err := registry.RegisterLauncher(mock) + assert.NoError(t, err) + }) + + t.Run("register duplicate launcher", func(t *testing.T) { + mock := &mockAppLauncher{tech: "golang"} + err := registry.RegisterLauncher(mock) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + }) + + t.Run("register multiple launchers", func(t *testing.T) { + launchers := []*mockAppLauncher{ + {tech: "nodejs"}, + {tech: "generic"}, + } + + for _, launcher := range launchers { + err := registry.RegisterLauncher(launcher) + assert.NoError(t, err) + } + }) +} + +func TestRegistry_GetLauncher(t *testing.T) { + registry := NewRegistry() + mock := &mockAppLauncher{tech: "nodejs"} + registry.RegisterLauncher(mock) + + t.Run("get existing launcher", func(t *testing.T) { + launcher, err := registry.GetLauncher("nodejs") + assert.NoError(t, err) + assert.NotNil(t, launcher) + assert.Equal(t, "nodejs", launcher.Technology()) + }) + + t.Run("get non-existent launcher", func(t *testing.T) { + launcher, err := registry.GetLauncher("rust") + assert.Error(t, err) + assert.Nil(t, launcher) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestRegistry_RegisterExecutor(t *testing.T) { + registry := NewRegistry() + + t.Run("register new executor", func(t *testing.T) { + mock := &mockTestExecutor{framework: "karate"} + err := registry.RegisterExecutor(mock) + assert.NoError(t, err) + }) + + t.Run("register duplicate executor", func(t *testing.T) { + mock := &mockTestExecutor{framework: "karate"} + err := registry.RegisterExecutor(mock) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + }) + + t.Run("register multiple executors", func(t *testing.T) { + mock := &mockTestExecutor{framework: "cypress"} + err := registry.RegisterExecutor(mock) + assert.NoError(t, err) + }) +} + +func TestRegistry_GetExecutor(t *testing.T) { + registry := NewRegistry() + mock := &mockTestExecutor{framework: "test-launcher-back"} + registry.RegisterExecutor(mock) + + t.Run("get existing executor", func(t *testing.T) { + executor, err := registry.GetExecutor("test-launcher-back") + assert.NoError(t, err) + assert.NotNil(t, executor) + assert.Equal(t, "test-launcher-back", executor.Framework()) + }) + + t.Run("get non-existent executor", func(t *testing.T) { + executor, err := registry.GetExecutor("jest") + assert.Error(t, err) + assert.Nil(t, executor) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestRegistry_ConcurrentAccess(t *testing.T) { + registry := NewRegistry() + + // Test concurrent service registration and retrieval + t.Run("concurrent service operations", func(t *testing.T) { + done := make(chan bool, 10) + + // Concurrent registrations + for i := 0; i < 5; i++ { + go func(id int) { + mock := &mockServicePlugin{name: fmt.Sprintf("service%d", id)} + registry.RegisterService(mock) + done <- true + }(i) + } + + // Concurrent retrievals + for i := 0; i < 5; i++ { + go func(id int) { + registry.GetService(fmt.Sprintf("service%d", id)) + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Verify all services were registered + services := registry.ListServices() + assert.GreaterOrEqual(t, len(services), 5) + }) +} + +func TestRegistry_FullWorkflow(t *testing.T) { + registry := NewRegistry() + + // Register all types of plugins + service := &mockServicePlugin{name: "couchbase"} + launcher := &mockAppLauncher{tech: "golang"} + executor := &mockTestExecutor{framework: "karate"} + + err := registry.RegisterService(service) + require.NoError(t, err) + + err = registry.RegisterLauncher(launcher) + require.NoError(t, err) + + err = registry.RegisterExecutor(executor) + require.NoError(t, err) + + // Retrieve and verify + retrievedService, err := registry.GetService("couchbase") + require.NoError(t, err) + assert.Equal(t, service, retrievedService) + + retrievedLauncher, err := registry.GetLauncher("golang") + require.NoError(t, err) + assert.Equal(t, launcher, retrievedLauncher) + + retrievedExecutor, err := registry.GetExecutor("karate") + require.NoError(t, err) + assert.Equal(t, executor, retrievedExecutor) +} + +func BenchmarkRegistry_RegisterService(b *testing.B) { + registry := NewRegistry() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + mock := &mockServicePlugin{name: fmt.Sprintf("service%d", i)} + registry.RegisterService(mock) + } +} + +func BenchmarkRegistry_GetService(b *testing.B) { + registry := NewRegistry() + mock := &mockServicePlugin{name: "test"} + registry.RegisterService(mock) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + registry.GetService("test") + } +} + +func BenchmarkRegistry_ListServices(b *testing.B) { + registry := NewRegistry() + + // Register 100 services + for i := 0; i < 100; i++ { + mock := &mockServicePlugin{name: fmt.Sprintf("service%d", i)} + registry.RegisterService(mock) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + registry.ListServices() + } +} diff --git a/internal/plugin/services/couchbase/couchbase.go b/internal/plugin/services/couchbase/couchbase.go new file mode 100644 index 0000000..a2564f4 --- /dev/null +++ b/internal/plugin/services/couchbase/couchbase.go @@ -0,0 +1,462 @@ +package couchbase + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "couchbase:community-7.6.2" + defaultAdminPort = "8091" + defaultDataPort = "11210" + defaultUsername = "Administrator" + defaultPassword = "password" + defaultBucket = "default" + defaultBucketRAMMB = 128 + defaultClusterRAMMB = 256 + defaultServices = "data,index,query" + containerNamePrefix = "gtool-couchbase" + + // couchbaseCLIBin is the path to the couchbase-cli tool in the official image. + couchbaseCLIBin = "/opt/couchbase/bin/couchbase-cli" + // clusterEndpoint is the in-container REST endpoint used by couchbase-cli. + clusterEndpoint = "localhost:8091" +) + +// CouchbasePlugin implements the ServicePlugin interface for a single-node +// Couchbase Server cluster. +type CouchbasePlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *CouchbaseConfig +} + +// CouchbaseConfig holds Couchbase-specific configuration +type CouchbaseConfig struct { + Image string `json:"image"` + AdminPort string `json:"admin-port"` + DataPort string `json:"data-port"` + Username string `json:"username"` + Password string `json:"password"` + Bucket string `json:"bucket"` + BucketRAMMB int `json:"bucket-ram-mb"` + ClusterRAMMB int `json:"cluster-ram-mb"` + Services string `json:"services"` + ContainerName string `json:"container-name"` +} + +// NewCouchbasePlugin creates a new Couchbase service plugin +func NewCouchbasePlugin(dockerClient *docker.Client, logger *zap.Logger) *CouchbasePlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &CouchbasePlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *CouchbasePlugin) Name() string { + return "couchbase" +} + +// Launch starts the Couchbase service and initializes a single-node cluster +func (p *CouchbasePlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching Couchbase service") + + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse Couchbase configuration") + } + p.config = cfg + + p.logger.Info("pulling Couchbase image", zap.String("image", cfg.Image)) + if err := p.docker.PullImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull Couchbase image") + } + + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + PortBindings: map[string]string{ + "8091": cfg.AdminPort, + "11210": cfg.DataPort, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "couchbase", + }, + } + + p.logger.Info("creating Couchbase container", + zap.String("name", cfg.ContainerName), + zap.String("admin-port", cfg.AdminPort)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create Couchbase container") + } + p.containerID = containerID + + p.logger.Info("starting Couchbase container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start Couchbase container") + } + + // Initialize the cluster (retries until the REST API is up) + p.logger.Info("initializing Couchbase cluster") + if err := p.initializeCluster(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "Couchbase cluster did not initialize") + } + + // Create the configured bucket + if cfg.Bucket != "" { + p.logger.Info("creating bucket", zap.String("bucket", cfg.Bucket)) + if err := p.createBucket(ctx, cfg); err != nil { + // Don't fail the launch if bucket creation fails, just log the error + p.logger.Error("failed to create bucket", + zap.Error(err), + zap.String("bucket", cfg.Bucket)) + } + } + + p.logger.Info("Couchbase service launched successfully", + zap.String("containerID", containerID), + zap.String("admin-port", cfg.AdminPort)) + + return nil +} + +// IsReady checks if the Couchbase cluster is healthy +func (p *CouchbasePlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "Couchbase container not started") + } + + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // server-info succeeds only once the cluster is initialized and serving + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{ + couchbaseCLIBin, "server-info", + "--cluster", clusterEndpoint, + "--username", p.config.Username, + "--password", p.config.Password, + }, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Debug("Couchbase not ready yet", zap.String("output", output)) + return false, nil + } + + return true, nil +} + +// Stop terminates the Couchbase service +func (p *CouchbasePlugin) Stop(ctx context.Context) error { + if p.containerID == "" { + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "couchbase", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Couchbase containers") + } + + if len(containers) == 0 { + p.logger.Warn("no Couchbase containers found to stop") + return nil + } + + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found Couchbase container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *CouchbasePlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping Couchbase service", zap.String("containerID", p.containerID)) + + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove Couchbase container") + } + + p.logger.Info("Couchbase service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *CouchbasePlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Couchbase service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.AdminPort), + Protocol: "couchbase", + Metadata: map[string]string{ + "username": p.config.Username, + "password": p.config.Password, + "bucket": p.config.Bucket, + "connection-string": "couchbase://localhost", + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *CouchbasePlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + if containerID == "" { + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "couchbase", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Couchbase containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Couchbase container not found") + } + + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found Couchbase container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into CouchbaseConfig +func (p *CouchbasePlugin) parseConfig(config map[string]interface{}) (*CouchbaseConfig, error) { + cfg := &CouchbaseConfig{ + Image: defaultImage, + AdminPort: defaultAdminPort, + DataPort: defaultDataPort, + Username: defaultUsername, + Password: defaultPassword, + Bucket: defaultBucket, + BucketRAMMB: defaultBucketRAMMB, + ClusterRAMMB: defaultClusterRAMMB, + Services: defaultServices, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + cfg.AdminPort = stringOrFloat(config, "admin-port", cfg.AdminPort) + cfg.DataPort = stringOrFloat(config, "data-port", cfg.DataPort) + if username, ok := config["username"].(string); ok && username != "" { + cfg.Username = username + } + if password, ok := config["password"].(string); ok && password != "" { + cfg.Password = password + } + if bucket, ok := config["bucket"].(string); ok && bucket != "" { + cfg.Bucket = bucket + } + if services, ok := config["services"].(string); ok && services != "" { + cfg.Services = services + } + cfg.BucketRAMMB = intOrFloat(config, "bucket-ram-mb", cfg.BucketRAMMB) + cfg.ClusterRAMMB = intOrFloat(config, "cluster-ram-mb", cfg.ClusterRAMMB) + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// initializeCluster runs couchbase-cli cluster-init, retrying until the REST +// API is available or the context is cancelled. +func (p *CouchbasePlugin) initializeCluster(ctx context.Context) error { + maxRetries := 30 + interval := 2 * time.Second + + cmd := []string{ + couchbaseCLIBin, "cluster-init", + "--cluster", clusterEndpoint, + "--cluster-username", p.config.Username, + "--cluster-password", p.config.Password, + "--services", p.config.Services, + "--cluster-ramsize", fmt.Sprintf("%d", p.config.ClusterRAMMB), + } + if strings.Contains(p.config.Services, "index") { + cmd = append(cmd, "--cluster-index-ramsize", fmt.Sprintf("%d", p.config.ClusterRAMMB)) + } + + for i := 0; i < maxRetries; i++ { + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) + if err == nil { + p.logger.Info("Couchbase cluster initialized", zap.Int("attempts", i+1)) + return nil + } + + p.logger.Debug("cluster not ready yet", + zap.Error(err), + zap.String("output", output), + zap.Int("attempt", i+1)) + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while initializing Couchbase") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("Couchbase cluster did not initialize after %d attempts", maxRetries)) +} + +// createBucket creates the configured bucket via couchbase-cli +func (p *CouchbasePlugin) createBucket(ctx context.Context, cfg *CouchbaseConfig) error { + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{ + couchbaseCLIBin, "bucket-create", + "--cluster", clusterEndpoint, + "--username", cfg.Username, + "--password", cfg.Password, + "--bucket", cfg.Bucket, + "--bucket-type", "couchbase", + "--bucket-ramsize", fmt.Sprintf("%d", cfg.BucketRAMMB), + "--wait", + }, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Error("failed to create bucket", + zap.Error(err), + zap.String("bucket", cfg.Bucket), + zap.String("output", output)) + return err + } + + p.logger.Info("bucket created successfully", + zap.String("bucket", cfg.Bucket), + zap.String("output", strings.TrimSpace(output))) + + return nil +} + +// mustParsePort parses port string to int, returns 0 on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} + +// stringOrFloat reads a config value that may be a string or a numeric (YAML/JSON +// may decode ports as float64), returning fallback when absent. +func stringOrFloat(config map[string]interface{}, key, fallback string) string { + if v, ok := config[key].(string); ok && v != "" { + return v + } + if v, ok := config[key].(float64); ok { + return fmt.Sprintf("%.0f", v) + } + return fallback +} + +// intOrFloat reads a positive integer config value (decoded as float64 or int), +// returning fallback when absent or non-positive. +func intOrFloat(config map[string]interface{}, key string, fallback int) int { + if v, ok := config[key].(float64); ok && v > 0 { + return int(v) + } + if v, ok := config[key].(int); ok && v > 0 { + return v + } + return fallback +} diff --git a/internal/plugin/services/couchbase/couchbase_test.go b/internal/plugin/services/couchbase/couchbase_test.go new file mode 100644 index 0000000..3356344 --- /dev/null +++ b/internal/plugin/services/couchbase/couchbase_test.go @@ -0,0 +1,212 @@ +package couchbase + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewCouchbasePlugin(t *testing.T) { + p := NewCouchbasePlugin(nil, zap.NewNop()) + + assert.NotNil(t, p) + assert.Equal(t, "couchbase", p.Name()) +} + +func TestName(t *testing.T) { + p := NewCouchbasePlugin(nil, nil) + assert.Equal(t, "couchbase", p.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + in map[string]interface{} + want *CouchbaseConfig + }{ + { + name: "default config", + in: map[string]interface{}{}, + want: &CouchbaseConfig{ + Image: defaultImage, + AdminPort: defaultAdminPort, + DataPort: defaultDataPort, + Username: defaultUsername, + Password: defaultPassword, + Bucket: defaultBucket, + BucketRAMMB: defaultBucketRAMMB, + ClusterRAMMB: defaultClusterRAMMB, + Services: defaultServices, + }, + }, + { + name: "full custom config", + in: map[string]interface{}{ + "image": "couchbase:community-7.2.0", + "admin-port": "18091", + "data-port": "21210", + "username": "admin", + "password": "secret", + "bucket": "orders", + "services": "data,query", + "bucket-ram-mb": float64(256), + "cluster-ram-mb": float64(512), + }, + want: &CouchbaseConfig{ + Image: "couchbase:community-7.2.0", + AdminPort: "18091", + DataPort: "21210", + Username: "admin", + Password: "secret", + Bucket: "orders", + Services: "data,query", + BucketRAMMB: 256, + ClusterRAMMB: 512, + }, + }, + { + name: "numeric admin port", + in: map[string]interface{}{ + "admin-port": float64(18091), + }, + want: &CouchbaseConfig{ + Image: defaultImage, + AdminPort: "18091", + DataPort: defaultDataPort, + Username: defaultUsername, + Password: defaultPassword, + Bucket: defaultBucket, + BucketRAMMB: defaultBucketRAMMB, + ClusterRAMMB: defaultClusterRAMMB, + Services: defaultServices, + }, + }, + { + name: "non-positive ram falls back to defaults", + in: map[string]interface{}{ + "bucket-ram-mb": float64(0), + "cluster-ram-mb": float64(-1), + }, + want: &CouchbaseConfig{ + Image: defaultImage, + AdminPort: defaultAdminPort, + DataPort: defaultDataPort, + Username: defaultUsername, + Password: defaultPassword, + Bucket: defaultBucket, + BucketRAMMB: defaultBucketRAMMB, + ClusterRAMMB: defaultClusterRAMMB, + Services: defaultServices, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewCouchbasePlugin(nil, nil) + got, err := p.parseConfig(tt.in) + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.AdminPort, got.AdminPort) + assert.Equal(t, tt.want.DataPort, got.DataPort) + assert.Equal(t, tt.want.Username, got.Username) + assert.Equal(t, tt.want.Password, got.Password) + assert.Equal(t, tt.want.Bucket, got.Bucket) + assert.Equal(t, tt.want.Services, got.Services) + assert.Equal(t, tt.want.BucketRAMMB, got.BucketRAMMB) + assert.Equal(t, tt.want.ClusterRAMMB, got.ClusterRAMMB) + assert.Contains(t, got.ContainerName, containerNamePrefix) + }) + } +} + +func TestParseConfig_CustomContainerName(t *testing.T) { + p := NewCouchbasePlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{"container-name": "my-cb"}) + + require.NoError(t, err) + assert.Equal(t, "my-cb", got.ContainerName) +} + +func TestGetConnectionInfo(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + p := NewCouchbasePlugin(nil, nil) + p.config = &CouchbaseConfig{ + AdminPort: "8091", + Username: "Administrator", + Password: "password", + Bucket: "default", + } + + got, err := p.GetConnectionInfo() + + require.NoError(t, err) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, 8091, got.Port) + assert.Equal(t, "couchbase", got.Protocol) + assert.Equal(t, "Administrator", got.Metadata["username"]) + assert.Equal(t, "password", got.Metadata["password"]) + assert.Equal(t, "default", got.Metadata["bucket"]) + assert.Equal(t, "couchbase://localhost", got.Metadata["connection-string"]) + }) + + t.Run("not launched", func(t *testing.T) { + p := NewCouchbasePlugin(nil, nil) + + _, err := p.GetConnectionInfo() + + require.Error(t, err) + assert.Contains(t, err.Error(), "not launched") + }) +} + +func TestIsReady_NotStarted(t *testing.T) { + p := NewCouchbasePlugin(nil, nil) + + ready, err := p.IsReady(nil) + + assert.False(t, ready) + require.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + p := NewCouchbasePlugin(nil, zap.NewNop()) + + err := p.Stop(nil) + assert.NoError(t, err) +} + +func TestStringOrFloat(t *testing.T) { + cfg := map[string]interface{}{ + "s": "value", + "f": float64(8091), + "e": "", + } + + assert.Equal(t, "value", stringOrFloat(cfg, "s", "fallback")) + assert.Equal(t, "8091", stringOrFloat(cfg, "f", "fallback")) + assert.Equal(t, "fallback", stringOrFloat(cfg, "e", "fallback")) + assert.Equal(t, "fallback", stringOrFloat(cfg, "missing", "fallback")) +} + +func TestIntOrFloat(t *testing.T) { + cfg := map[string]interface{}{ + "f": float64(256), + "i": 512, + "zero": float64(0), + } + + assert.Equal(t, 256, intOrFloat(cfg, "f", 10)) + assert.Equal(t, 512, intOrFloat(cfg, "i", 10)) + assert.Equal(t, 10, intOrFloat(cfg, "zero", 10)) + assert.Equal(t, 10, intOrFloat(cfg, "missing", 10)) +} + +func TestMustParsePort(t *testing.T) { + assert.Equal(t, 8091, mustParsePort("8091")) + assert.Equal(t, 0, mustParsePort("0")) +} diff --git a/internal/plugin/services/couchbase/integration_test.go b/internal/plugin/services/couchbase/integration_test.go new file mode 100644 index 0000000..bc9cc67 --- /dev/null +++ b/internal/plugin/services/couchbase/integration_test.go @@ -0,0 +1,95 @@ +//go:build integration +// +build integration + +package couchbase + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +func TestCouchbaseIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := logger.Default() + defer log.Sync() + + dockerClient, err := docker.NewClient(log.Logger) + require.NoError(t, err, "Failed to create Docker client") + defer dockerClient.Close() + + ctx := context.Background() + err = dockerClient.Ping(ctx) + require.NoError(t, err, "Docker daemon not available") + + plugin := NewCouchbasePlugin(dockerClient, log.Logger) + require.NotNil(t, plugin) + + config := map[string]interface{}{ + "image": defaultImage, + "admin-port": "18091", + "data-port": "21210", + "bucket": "orders", + } + + log.Info("launching Couchbase for integration test") + err = plugin.Launch(ctx, config) + require.NoError(t, err, "Failed to launch Couchbase") + + defer func() { + log.Info("cleaning up Couchbase container") + if err := plugin.Stop(ctx); err != nil { + t.Logf("Failed to stop Couchbase: %v", err) + } + }() + + t.Run("IsReady", func(t *testing.T) { + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "Couchbase should be ready") + }) + + t.Run("GetConnectionInfo", func(t *testing.T) { + connInfo, err := plugin.GetConnectionInfo() + require.NoError(t, err) + assert.Equal(t, "localhost", connInfo.Host) + assert.Equal(t, 18091, connInfo.Port) + assert.Equal(t, "couchbase", connInfo.Protocol) + assert.Equal(t, "orders", connInfo.Metadata["bucket"]) + }) + + t.Run("BucketCreated", func(t *testing.T) { + output, err := dockerClient.ExecInContainer(ctx, plugin.containerID, &docker.ExecConfig{ + Cmd: []string{ + couchbaseCLIBin, "bucket-list", + "--cluster", clusterEndpoint, + "--username", defaultUsername, + "--password", defaultPassword, + }, + AttachStdout: true, + AttachStderr: true, + }) + require.NoError(t, err, "Failed to list buckets: %s", output) + assert.True(t, strings.Contains(output, "orders"), "should list the 'orders' bucket") + }) + + t.Run("GetLogs", func(t *testing.T) { + logs, err := plugin.GetLogs(ctx, nil) + require.NoError(t, err) + assert.NotEmpty(t, logs, "Should have logs") + }) + + t.Run("Stop", func(t *testing.T) { + err := plugin.Stop(ctx) + require.NoError(t, err, "Failed to stop Couchbase") + }) +} diff --git a/internal/plugin/services/gcs/gcs.go b/internal/plugin/services/gcs/gcs.go new file mode 100644 index 0000000..2da05c3 --- /dev/null +++ b/internal/plugin/services/gcs/gcs.go @@ -0,0 +1,410 @@ +package gcs + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "fsouza/fake-gcs-server:1.49.0" + defaultPort = "4443" + defaultProjectID = "test-project" + containerNamePrefix = "gtool-gcs" +) + +// GCSPlugin implements the ServicePlugin interface for the fake-gcs-server +// Google Cloud Storage emulator. +type GCSPlugin struct { + docker *docker.Client + logger *zap.Logger + httpClient *http.Client + containerID string + config *GCSConfig +} + +// GCSConfig holds GCS-specific configuration +type GCSConfig struct { + Image string `json:"image"` + Port string `json:"port"` + ProjectID string `json:"project-id"` + Buckets []string `json:"buckets"` + ContainerName string `json:"container-name"` +} + +// NewGCSPlugin creates a new GCS service plugin +func NewGCSPlugin(dockerClient *docker.Client, logger *zap.Logger) *GCSPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &GCSPlugin{ + docker: dockerClient, + logger: logger, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Name returns the service identifier +func (p *GCSPlugin) Name() string { + return "gcs" +} + +// Launch starts the GCS emulator with given configuration +func (p *GCSPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching GCS emulator") + + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse GCS configuration") + } + p.config = cfg + + p.logger.Info("pulling GCS image", zap.String("image", cfg.Image)) + if err := p.docker.PullImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull GCS image") + } + + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + Cmd: buildCmd(cfg), + PortBindings: map[string]string{ + "4443": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "gcs", + }, + } + + p.logger.Info("creating GCS container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create GCS container") + } + p.containerID = containerID + + p.logger.Info("starting GCS container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start GCS container") + } + + p.logger.Info("waiting for GCS emulator to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "GCS emulator did not become ready") + } + + // Create the configured buckets + if len(cfg.Buckets) > 0 { + p.logger.Info("creating buckets", zap.Strings("buckets", cfg.Buckets)) + if err := p.createBuckets(ctx, cfg); err != nil { + // Don't fail the launch if bucket creation fails, just log the error + p.logger.Error("failed to create buckets", zap.Error(err)) + } + } + + p.logger.Info("GCS emulator launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the GCS emulator REST API is accepting requests +func (p *GCSPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "GCS container not started") + } + + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Listing buckets succeeds only once the emulator is serving requests + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.bucketsURL(), nil) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build readiness request") + } + + resp, err := p.httpClient.Do(req) + if err != nil { + p.logger.Debug("GCS not ready yet", zap.Error(err)) + return false, nil + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK, nil +} + +// Stop terminates the GCS service +func (p *GCSPlugin) Stop(ctx context.Context) error { + if p.containerID == "" { + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "gcs", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list GCS containers") + } + + if len(containers) == 0 { + p.logger.Warn("no GCS containers found to stop") + return nil + } + + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found GCS container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *GCSPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping GCS service", zap.String("containerID", p.containerID)) + + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove GCS container") + } + + p.logger.Info("GCS service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *GCSPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "GCS service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "http", + Metadata: map[string]string{ + "storage-emulator-host": fmt.Sprintf("http://localhost:%s", p.config.Port), + "project-id": p.config.ProjectID, + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *GCSPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + if containerID == "" { + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "gcs", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list GCS containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "GCS container not found") + } + + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found GCS container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into GCSConfig +func (p *GCSPlugin) parseConfig(config map[string]interface{}) (*GCSConfig, error) { + cfg := &GCSConfig{ + Image: defaultImage, + Port: defaultPort, + ProjectID: defaultProjectID, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if projectID, ok := config["project-id"].(string); ok && projectID != "" { + cfg.ProjectID = projectID + } + if buckets, ok := config["buckets"].([]interface{}); ok { + for _, b := range buckets { + if name, ok := b.(string); ok && name != "" { + cfg.Buckets = append(cfg.Buckets, name) + } + } + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for the GCS emulator to start serving requests +func (p *GCSPlugin) waitForReady(ctx context.Context) error { + maxRetries := 30 + interval := time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("GCS emulator is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for GCS") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("GCS emulator did not become ready after %d attempts", maxRetries)) +} + +// createBuckets creates the configured buckets via the GCS JSON API +func (p *GCSPlugin) createBuckets(ctx context.Context, cfg *GCSConfig) error { + for _, bucket := range cfg.Buckets { + body := []byte(fmt.Sprintf(`{"name":%q}`, bucket)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.bucketsURL(), bytes.NewReader(body)) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build bucket request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to create bucket: %s", bucket)) + } + ok := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusConflict + resp.Body.Close() + + if !ok { + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("unexpected status %d creating bucket %s", resp.StatusCode, bucket)) + } + + p.logger.Info("bucket created", zap.String("bucket", bucket)) + } + return nil +} + +// bucketsURL returns the GCS JSON API buckets endpoint for the project +func (p *GCSPlugin) bucketsURL() string { + return fmt.Sprintf("http://localhost:%s/storage/v1/b?project=%s", p.config.Port, p.config.ProjectID) +} + +// buildCmd builds the fake-gcs-server startup command. external-url and +// public-host are set to the host-facing address so object URLs resolve for +// clients running on the host. +func buildCmd(cfg *GCSConfig) []string { + hostURL := fmt.Sprintf("http://localhost:%s", cfg.Port) + return []string{ + "-scheme", "http", + "-host", "0.0.0.0", + "-port", "4443", + "-external-url", hostURL, + "-public-host", fmt.Sprintf("localhost:%s", cfg.Port), + } +} + +// mustParsePort parses port string to int, returns 0 on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/gcs/gcs_test.go b/internal/plugin/services/gcs/gcs_test.go new file mode 100644 index 0000000..666ec60 --- /dev/null +++ b/internal/plugin/services/gcs/gcs_test.go @@ -0,0 +1,124 @@ +package gcs + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewGCSPlugin(t *testing.T) { + p := NewGCSPlugin(nil, zap.NewNop()) + + assert.NotNil(t, p) + assert.Equal(t, "gcs", p.Name()) + assert.NotNil(t, p.httpClient) +} + +func TestName(t *testing.T) { + p := NewGCSPlugin(nil, nil) + assert.Equal(t, "gcs", p.Name()) +} + +func TestParseConfig(t *testing.T) { + t.Run("default config", func(t *testing.T) { + p := NewGCSPlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{}) + + require.NoError(t, err) + assert.Equal(t, defaultImage, got.Image) + assert.Equal(t, defaultPort, got.Port) + assert.Equal(t, defaultProjectID, got.ProjectID) + assert.Empty(t, got.Buckets) + assert.Contains(t, got.ContainerName, containerNamePrefix) + }) + + t.Run("buckets, project and numeric port", func(t *testing.T) { + p := NewGCSPlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{ + "port": float64(14443), + "project-id": "my-project", + "buckets": []interface{}{"uploads", "", 42, "exports"}, + }) + + require.NoError(t, err) + assert.Equal(t, "14443", got.Port) + assert.Equal(t, "my-project", got.ProjectID) + assert.Equal(t, []string{"uploads", "exports"}, got.Buckets) + }) + + t.Run("custom container name", func(t *testing.T) { + p := NewGCSPlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{"container-name": "my-gcs"}) + + require.NoError(t, err) + assert.Equal(t, "my-gcs", got.ContainerName) + }) +} + +func TestGetConnectionInfo(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + p := NewGCSPlugin(nil, nil) + p.config = &GCSConfig{Port: "4443", ProjectID: "test-project"} + + got, err := p.GetConnectionInfo() + + require.NoError(t, err) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, 4443, got.Port) + assert.Equal(t, "http", got.Protocol) + assert.Equal(t, "http://localhost:4443", got.Metadata["storage-emulator-host"]) + assert.Equal(t, "test-project", got.Metadata["project-id"]) + }) + + t.Run("not launched", func(t *testing.T) { + p := NewGCSPlugin(nil, nil) + + _, err := p.GetConnectionInfo() + + require.Error(t, err) + assert.Contains(t, err.Error(), "not launched") + }) +} + +func TestBucketsURL(t *testing.T) { + p := NewGCSPlugin(nil, nil) + p.config = &GCSConfig{Port: "4443", ProjectID: "test-project"} + + assert.Equal(t, "http://localhost:4443/storage/v1/b?project=test-project", p.bucketsURL()) +} + +func TestBuildCmd(t *testing.T) { + cmd := buildCmd(&GCSConfig{Port: "14443"}) + + assert.Equal(t, []string{ + "-scheme", "http", + "-host", "0.0.0.0", + "-port", "4443", + "-external-url", "http://localhost:14443", + "-public-host", "localhost:14443", + }, cmd) +} + +func TestIsReady_NotStarted(t *testing.T) { + p := NewGCSPlugin(nil, nil) + + ready, err := p.IsReady(nil) + + assert.False(t, ready) + require.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + p := NewGCSPlugin(nil, zap.NewNop()) + + err := p.Stop(nil) + assert.NoError(t, err) +} + +func TestMustParsePort(t *testing.T) { + assert.Equal(t, 4443, mustParsePort("4443")) + assert.Equal(t, 0, mustParsePort("0")) +} diff --git a/internal/plugin/services/gcs/integration_test.go b/internal/plugin/services/gcs/integration_test.go new file mode 100644 index 0000000..f6e3e19 --- /dev/null +++ b/internal/plugin/services/gcs/integration_test.go @@ -0,0 +1,86 @@ +//go:build integration +// +build integration + +package gcs + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +func TestGCSIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := logger.Default() + defer log.Sync() + + dockerClient, err := docker.NewClient(log.Logger) + require.NoError(t, err, "Failed to create Docker client") + defer dockerClient.Close() + + ctx := context.Background() + err = dockerClient.Ping(ctx) + require.NoError(t, err, "Docker daemon not available") + + plugin := NewGCSPlugin(dockerClient, log.Logger) + require.NotNil(t, plugin) + + const port = "14443" + config := map[string]interface{}{ + "image": defaultImage, + "port": port, + "project-id": "test-project", + "buckets": []interface{}{"uploads"}, + } + + log.Info("launching GCS emulator for integration test") + err = plugin.Launch(ctx, config) + require.NoError(t, err, "Failed to launch GCS") + + defer func() { + log.Info("cleaning up GCS container") + if err := plugin.Stop(ctx); err != nil { + t.Logf("Failed to stop GCS: %v", err) + } + }() + + t.Run("IsReady", func(t *testing.T) { + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "GCS should be ready") + }) + + t.Run("GetConnectionInfo", func(t *testing.T) { + connInfo, err := plugin.GetConnectionInfo() + require.NoError(t, err) + assert.Equal(t, "localhost", connInfo.Host) + assert.Equal(t, 14443, connInfo.Port) + assert.Equal(t, "test-project", connInfo.Metadata["project-id"]) + }) + + t.Run("BucketCreated", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/storage/v1/b?project=test-project", port)) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, strings.Contains(string(body), "uploads"), "should list the 'uploads' bucket") + }) + + t.Run("Stop", func(t *testing.T) { + err := plugin.Stop(ctx) + require.NoError(t, err, "Failed to stop GCS") + }) +} diff --git a/internal/plugin/services/init.go b/internal/plugin/services/init.go new file mode 100644 index 0000000..2a92bc5 --- /dev/null +++ b/internal/plugin/services/init.go @@ -0,0 +1,73 @@ +package services + +import ( + "go.uber.org/zap" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + "github.com/oswaldom-code/gtool/internal/plugin/services/couchbase" + "github.com/oswaldom-code/gtool/internal/plugin/services/gcs" + "github.com/oswaldom-code/gtool/internal/plugin/services/kafka" + "github.com/oswaldom-code/gtool/internal/plugin/services/minio" + "github.com/oswaldom-code/gtool/internal/plugin/services/mongodb" + "github.com/oswaldom-code/gtool/internal/plugin/services/mountebank" + "github.com/oswaldom-code/gtool/internal/plugin/services/mysql" + "github.com/oswaldom-code/gtool/internal/plugin/services/postgresql" + "github.com/oswaldom-code/gtool/internal/plugin/services/pubsub" + "github.com/oswaldom-code/gtool/internal/plugin/services/redis" +) + +func RegisterAll(registry *plugin.Registry, dockerClient *docker.Client, logger *zap.Logger) error { + postgresPlugin := postgresql.NewPostgreSQLPlugin(dockerClient, logger) + if err := registry.RegisterService(postgresPlugin); err != nil { + return err + } + + mountebankPlugin := mountebank.NewMountebankPlugin(dockerClient, logger) + if err := registry.RegisterService(mountebankPlugin); err != nil { + return err + } + + kafkaPlugin := kafka.NewKafkaPlugin(dockerClient, logger) + if err := registry.RegisterService(kafkaPlugin); err != nil { + return err + } + + couchbasePlugin := couchbase.NewCouchbasePlugin(dockerClient, logger) + if err := registry.RegisterService(couchbasePlugin); err != nil { + return err + } + + pubsubPlugin := pubsub.NewPubSubPlugin(dockerClient, logger) + if err := registry.RegisterService(pubsubPlugin); err != nil { + return err + } + + gcsPlugin := gcs.NewGCSPlugin(dockerClient, logger) + if err := registry.RegisterService(gcsPlugin); err != nil { + return err + } + + redisPlugin := redis.NewRedisPlugin(dockerClient, logger) + if err := registry.RegisterService(redisPlugin); err != nil { + return err + } + + mongoPlugin := mongodb.NewMongoDBPlugin(dockerClient, logger) + if err := registry.RegisterService(mongoPlugin); err != nil { + return err + } + + mysqlPlugin := mysql.NewMySQLPlugin(dockerClient, logger) + if err := registry.RegisterService(mysqlPlugin); err != nil { + return err + } + + minioPlugin := minio.NewMinIOPlugin(dockerClient, logger) + if err := registry.RegisterService(minioPlugin); err != nil { + return err + } + + logger.Info("all service plugins registered successfully") + return nil +} diff --git a/internal/plugin/services/kafka/integration_test.go b/internal/plugin/services/kafka/integration_test.go new file mode 100644 index 0000000..5af0698 --- /dev/null +++ b/internal/plugin/services/kafka/integration_test.go @@ -0,0 +1,91 @@ +//go:build integration +// +build integration + +package kafka + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +func TestKafkaIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := logger.Default() + defer log.Sync() + + dockerClient, err := docker.NewClient(log.Logger) + require.NoError(t, err, "Failed to create Docker client") + defer dockerClient.Close() + + ctx := context.Background() + err = dockerClient.Ping(ctx) + require.NoError(t, err, "Docker daemon not available") + + plugin := NewKafkaPlugin(dockerClient, log.Logger) + require.NotNil(t, plugin) + + config := map[string]interface{}{ + "image": defaultImage, + "port": "19092", + "partitions": float64(2), + "topics": []interface{}{"orders", "payments"}, + } + + log.Info("launching Kafka for integration test") + err = plugin.Launch(ctx, config) + require.NoError(t, err, "Failed to launch Kafka") + + defer func() { + log.Info("cleaning up Kafka container") + if err := plugin.Stop(ctx); err != nil { + t.Logf("Failed to stop Kafka: %v", err) + } + }() + + t.Run("IsReady", func(t *testing.T) { + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "Kafka should be ready") + }) + + t.Run("GetConnectionInfo", func(t *testing.T) { + connInfo, err := plugin.GetConnectionInfo() + require.NoError(t, err) + assert.Equal(t, "localhost", connInfo.Host) + assert.Equal(t, 19092, connInfo.Port) + assert.Equal(t, "kafka", connInfo.Protocol) + assert.Equal(t, "localhost:19092", connInfo.Metadata["bootstrap-servers"]) + }) + + t.Run("TopicsCreated", func(t *testing.T) { + output, err := dockerClient.ExecInContainer(ctx, plugin.containerID, &docker.ExecConfig{ + Cmd: []string{kafkaTopicsBin, "--bootstrap-server", internalBootstrap, "--list"}, + AttachStdout: true, + AttachStderr: true, + }) + require.NoError(t, err, "Failed to list topics: %s", output) + assert.True(t, strings.Contains(output, "orders"), "should list 'orders' topic") + assert.True(t, strings.Contains(output, "payments"), "should list 'payments' topic") + }) + + t.Run("GetLogs", func(t *testing.T) { + logs, err := plugin.GetLogs(ctx, nil) + require.NoError(t, err) + assert.NotEmpty(t, logs, "Should have logs") + }) + + t.Run("Stop", func(t *testing.T) { + err := plugin.Stop(ctx) + require.NoError(t, err, "Failed to stop Kafka") + }) +} diff --git a/internal/plugin/services/kafka/kafka.go b/internal/plugin/services/kafka/kafka.go new file mode 100644 index 0000000..ddb8277 --- /dev/null +++ b/internal/plugin/services/kafka/kafka.go @@ -0,0 +1,436 @@ +package kafka + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "apache/kafka:3.7.0" + defaultPort = "9092" + defaultPartitions = 1 + containerNamePrefix = "gtool-kafka" + + // internalBootstrap is the in-container listener used for admin operations + // (health check, topic creation). It is independent of the host port so it + // works regardless of how the external listener is mapped. + internalBootstrap = "localhost:29092" + // kafkaTopicsBin is the path to the kafka-topics CLI in the apache/kafka image. + kafkaTopicsBin = "/opt/kafka/bin/kafka-topics.sh" +) + +// KafkaPlugin implements the ServicePlugin interface for a single-node Kafka +// broker running in KRaft mode. +type KafkaPlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *KafkaConfig +} + +// KafkaConfig holds Kafka-specific configuration +type KafkaConfig struct { + Image string `json:"image"` + Port string `json:"port"` + Topics []string `json:"topics"` + Partitions int `json:"partitions"` + ContainerName string `json:"container-name"` +} + +// NewKafkaPlugin creates a new Kafka service plugin +func NewKafkaPlugin(dockerClient *docker.Client, logger *zap.Logger) *KafkaPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &KafkaPlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *KafkaPlugin) Name() string { + return "kafka" +} + +// Launch starts the Kafka broker with given configuration +func (p *KafkaPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching Kafka service") + + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse Kafka configuration") + } + p.config = cfg + + p.logger.Info("pulling Kafka image", zap.String("image", cfg.Image)) + if err := p.docker.PullImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull Kafka image") + } + + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + Env: p.brokerEnv(cfg), + PortBindings: map[string]string{ + "9092": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "kafka", + }, + } + + p.logger.Info("creating Kafka container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create Kafka container") + } + p.containerID = containerID + + p.logger.Info("starting Kafka container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start Kafka container") + } + + p.logger.Info("waiting for Kafka to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "Kafka did not become ready") + } + + // Create topics if any are configured + if len(cfg.Topics) > 0 { + p.logger.Info("creating topics", zap.Strings("topics", cfg.Topics)) + if err := p.createTopics(ctx, cfg.Topics, cfg.Partitions); err != nil { + // Don't fail the launch if topic creation fails, just log the error + p.logger.Error("failed to create topics", + zap.Error(err), + zap.Strings("topics", cfg.Topics)) + } + } + + p.logger.Info("Kafka service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the Kafka broker is accepting requests +func (p *KafkaPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "Kafka container not started") + } + + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Listing topics succeeds only once the broker is serving requests + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{kafkaTopicsBin, "--bootstrap-server", internalBootstrap, "--list"}, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Debug("Kafka not ready yet", zap.String("output", output)) + return false, nil + } + + return true, nil +} + +// Stop terminates the Kafka service +func (p *KafkaPlugin) Stop(ctx context.Context) error { + if p.containerID == "" { + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "kafka", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Kafka containers") + } + + if len(containers) == 0 { + p.logger.Warn("no Kafka containers found to stop") + return nil + } + + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found Kafka container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *KafkaPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping Kafka service", zap.String("containerID", p.containerID)) + + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove Kafka container") + } + + p.logger.Info("Kafka service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *KafkaPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Kafka service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "kafka", + Metadata: map[string]string{ + "bootstrap-servers": p.bootstrapServers(), + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *KafkaPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + if containerID == "" { + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "kafka", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Kafka containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Kafka container not found") + } + + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found Kafka container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into KafkaConfig +func (p *KafkaPlugin) parseConfig(config map[string]interface{}) (*KafkaConfig, error) { + cfg := &KafkaConfig{ + Image: defaultImage, + Port: defaultPort, + Partitions: defaultPartitions, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if partitions, ok := config["partitions"].(float64); ok && partitions > 0 { + cfg.Partitions = int(partitions) + } else if partitions, ok := config["partitions"].(int); ok && partitions > 0 { + cfg.Partitions = partitions + } + if topics, ok := config["topics"].([]interface{}); ok { + for _, t := range topics { + if name, ok := t.(string); ok && name != "" { + cfg.Topics = append(cfg.Topics, name) + } + } + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// brokerEnv builds the KRaft single-node broker environment. Two PLAINTEXT +// listeners are used: INTERNAL for in-container admin operations and EXTERNAL +// (advertised on the host port) for clients running on the host. +func (p *KafkaPlugin) brokerEnv(cfg *KafkaConfig) []string { + return []string{ + "KAFKA_NODE_ID=1", + "KAFKA_PROCESS_ROLES=broker,controller", + "KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9093", + "KAFKA_LISTENERS=INTERNAL://:29092,EXTERNAL://:9092,CONTROLLER://:9093", + fmt.Sprintf("KAFKA_ADVERTISED_LISTENERS=INTERNAL://%s,EXTERNAL://localhost:%s", internalBootstrap, cfg.Port), + "KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT", + "KAFKA_INTER_BROKER_LISTENER_NAME=INTERNAL", + "KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER", + "KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1", + "KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1", + "KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1", + "KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0", + } +} + +// waitForReady waits for the Kafka broker to start serving requests +func (p *KafkaPlugin) waitForReady(ctx context.Context) error { + maxRetries := 30 + interval := 2 * time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("Kafka is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for Kafka") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("Kafka did not become ready after %d attempts", maxRetries)) +} + +// createTopics creates the configured topics inside the broker +func (p *KafkaPlugin) createTopics(ctx context.Context, topics []string, partitions int) error { + for _, topic := range topics { + if err := p.createTopic(ctx, topic, partitions); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to create topic: %s", topic)) + } + } + return nil +} + +// createTopic creates a single topic with replication-factor 1 (single node) +func (p *KafkaPlugin) createTopic(ctx context.Context, topic string, partitions int) error { + p.logger.Info("creating topic", zap.String("topic", topic), zap.Int("partitions", partitions)) + + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{ + kafkaTopicsBin, + "--bootstrap-server", internalBootstrap, + "--create", + "--if-not-exists", + "--topic", topic, + "--partitions", fmt.Sprintf("%d", partitions), + "--replication-factor", "1", + }, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Error("failed to create topic", + zap.Error(err), + zap.String("topic", topic), + zap.String("output", output)) + return err + } + + p.logger.Info("topic created successfully", + zap.String("topic", topic), + zap.String("output", strings.TrimSpace(output))) + + return nil +} + +// bootstrapServers returns the host-facing bootstrap server address +func (p *KafkaPlugin) bootstrapServers() string { + return fmt.Sprintf("localhost:%s", p.config.Port) +} + +// mustParsePort parses port string to int, returns 0 on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/kafka/kafka_test.go b/internal/plugin/services/kafka/kafka_test.go new file mode 100644 index 0000000..49d5b8d --- /dev/null +++ b/internal/plugin/services/kafka/kafka_test.go @@ -0,0 +1,179 @@ +package kafka + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewKafkaPlugin(t *testing.T) { + p := NewKafkaPlugin(nil, zap.NewNop()) + + assert.NotNil(t, p) + assert.Equal(t, "kafka", p.Name()) +} + +func TestName(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + assert.Equal(t, "kafka", p.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + wantImage string + wantPort string + wantPartitions int + wantTopics []string + }{ + { + name: "default config", + input: map[string]interface{}{}, + wantImage: defaultImage, + wantPort: defaultPort, + wantPartitions: defaultPartitions, + wantTopics: nil, + }, + { + name: "custom config with topics", + input: map[string]interface{}{ + "image": "apache/kafka:3.6.0", + "port": "19092", + "partitions": float64(3), + "topics": []interface{}{"orders", "payments"}, + }, + wantImage: "apache/kafka:3.6.0", + wantPort: "19092", + wantPartitions: 3, + wantTopics: []string{"orders", "payments"}, + }, + { + name: "numeric port", + input: map[string]interface{}{ + "port": float64(19092), + }, + wantImage: defaultImage, + wantPort: "19092", + wantPartitions: defaultPartitions, + }, + { + name: "topics list ignores non-string and empty entries", + input: map[string]interface{}{ + "topics": []interface{}{"orders", "", 42, "events"}, + }, + wantImage: defaultImage, + wantPort: defaultPort, + wantPartitions: defaultPartitions, + wantTopics: []string{"orders", "events"}, + }, + { + name: "zero partitions falls back to default", + input: map[string]interface{}{ + "partitions": float64(0), + }, + wantImage: defaultImage, + wantPort: defaultPort, + wantPartitions: defaultPartitions, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + got, err := p.parseConfig(tt.input) + + require.NoError(t, err) + assert.Equal(t, tt.wantImage, got.Image) + assert.Equal(t, tt.wantPort, got.Port) + assert.Equal(t, tt.wantPartitions, got.Partitions) + assert.Equal(t, tt.wantTopics, got.Topics) + assert.Contains(t, got.ContainerName, containerNamePrefix) + }) + } +} + +func TestParseConfig_CustomContainerName(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{"container-name": "my-kafka"}) + + require.NoError(t, err) + assert.Equal(t, "my-kafka", got.ContainerName) +} + +func TestGetConnectionInfo(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + p.config = &KafkaConfig{Port: "9092"} + + got, err := p.GetConnectionInfo() + + require.NoError(t, err) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, 9092, got.Port) + assert.Equal(t, "kafka", got.Protocol) + assert.Equal(t, "localhost:9092", got.Metadata["bootstrap-servers"]) + }) + + t.Run("not launched", func(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + + _, err := p.GetConnectionInfo() + + require.Error(t, err) + assert.Contains(t, err.Error(), "not launched") + }) +} + +func TestBrokerEnv(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + env := p.brokerEnv(&KafkaConfig{Port: "19092"}) + + // External listener must be advertised on the configured host port. + assert.Contains(t, env, "KAFKA_ADVERTISED_LISTENERS=INTERNAL://localhost:29092,EXTERNAL://localhost:19092") + assert.Contains(t, env, "KAFKA_PROCESS_ROLES=broker,controller") + assert.Contains(t, env, "KAFKA_INTER_BROKER_LISTENER_NAME=INTERNAL") +} + +func TestBootstrapServers(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + p.config = &KafkaConfig{Port: "19092"} + + assert.Equal(t, "localhost:19092", p.bootstrapServers()) +} + +func TestIsReady_NotStarted(t *testing.T) { + p := NewKafkaPlugin(nil, nil) + + ready, err := p.IsReady(nil) + + assert.False(t, ready) + require.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + p := NewKafkaPlugin(nil, zap.NewNop()) + + err := p.Stop(nil) + assert.NoError(t, err) +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"9092", 9092}, + {"19092", 19092}, + {"0", 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, mustParsePort(tt.input)) + }) + } +} diff --git a/internal/plugin/services/minio/minio.go b/internal/plugin/services/minio/minio.go new file mode 100644 index 0000000..9f98435 --- /dev/null +++ b/internal/plugin/services/minio/minio.go @@ -0,0 +1,365 @@ +package minio + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "minio/minio:latest" + defaultPort = "9000" + defaultConsolePort = "9001" + defaultAccessKey = "minioadmin" + defaultSecretKey = "minioadmin" + containerNamePrefix = "gtool-minio" +) + +// MinIOPlugin implements the ServicePlugin interface for MinIO (S3-compatible storage) +type MinIOPlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *MinIOConfig +} + +// MinIOConfig holds MinIO-specific configuration +type MinIOConfig struct { + Image string `json:"image"` + Port string `json:"port"` + ConsolePort string `json:"console-port"` + AccessKey string `json:"access-key"` + SecretKey string `json:"secret-key"` + ContainerName string `json:"container-name"` +} + +// NewMinIOPlugin creates a new MinIO service plugin +func NewMinIOPlugin(dockerClient *docker.Client, logger *zap.Logger) *MinIOPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &MinIOPlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *MinIOPlugin) Name() string { + return "minio" +} + +// Launch starts the MinIO service with given configuration +func (p *MinIOPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching MinIO service") + + // Parse configuration + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse MinIO configuration") + } + p.config = cfg + + // Pull image + p.logger.Info("pulling MinIO image", zap.String("image", cfg.Image)) + if err := p.docker.EnsureImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull MinIO image") + } + + // Create container + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + Env: []string{ + fmt.Sprintf("MINIO_ROOT_USER=%s", cfg.AccessKey), + fmt.Sprintf("MINIO_ROOT_PASSWORD=%s", cfg.SecretKey), + }, + Cmd: []string{"server", "/data", "--console-address", ":9001"}, + PortBindings: map[string]string{ + "9000": cfg.Port, + "9001": cfg.ConsolePort, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "minio", + }, + } + + p.logger.Info("creating MinIO container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port), + zap.String("consolePort", cfg.ConsolePort)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create MinIO container") + } + p.containerID = containerID + + // Start container + p.logger.Info("starting MinIO container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start MinIO container") + } + + // Wait for MinIO to be ready + p.logger.Info("waiting for MinIO to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "MinIO did not become ready") + } + + p.logger.Info("MinIO service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the MinIO service is ready to accept connections +func (p *MinIOPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "MinIO container not started") + } + + // The minio/minio image does not reliably bundle curl or the mc client, so + // we cannot probe the health endpoint via ExecInContainer. Readiness is + // therefore limited to checking that the container is running (weak + // readiness, improvable in the future). + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + return running, nil +} + +// Stop terminates the MinIO service +func (p *MinIOPlugin) Stop(ctx context.Context) error { + // If no containerID, try to find container by labels + if p.containerID == "" { + // If no Docker client, nothing to stop + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "minio", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list MinIO containers") + } + + if len(containers) == 0 { + p.logger.Warn("no MinIO containers found to stop") + return nil + } + + // Stop all matching containers + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found MinIO container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *MinIOPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping MinIO service", zap.String("containerID", p.containerID)) + + // Stop container + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + // Remove container + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove MinIO container") + } + + p.logger.Info("MinIO service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *MinIOPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "MinIO service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "s3", + Metadata: map[string]string{ + "accessKey": p.config.AccessKey, + "secretKey": p.config.SecretKey, + "endpoint": fmt.Sprintf("http://localhost:%s", p.config.Port), + "consolePort": p.config.ConsolePort, + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *MinIOPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + // If no containerID, try to find container by labels + if containerID == "" { + // If no Docker client, cannot get logs + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "minio", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list MinIO containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "MinIO container not found") + } + + // Find first running container + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + // Fallback to first container if none are running + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found MinIO container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + // Split logs into lines + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into MinIOConfig +func (p *MinIOPlugin) parseConfig(config map[string]interface{}) (*MinIOConfig, error) { + cfg := &MinIOConfig{ + Image: defaultImage, + Port: defaultPort, + ConsolePort: defaultConsolePort, + AccessKey: defaultAccessKey, + SecretKey: defaultSecretKey, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + // Override with provided values + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if consolePort, ok := config["console-port"].(string); ok && consolePort != "" { + cfg.ConsolePort = consolePort + } else if consolePort, ok := config["console-port"].(float64); ok { + cfg.ConsolePort = fmt.Sprintf("%.0f", consolePort) + } + if accessKey, ok := config["access-key"].(string); ok && accessKey != "" { + cfg.AccessKey = accessKey + } + if secretKey, ok := config["secret-key"].(string); ok && secretKey != "" { + cfg.SecretKey = secretKey + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for MinIO to be ready +func (p *MinIOPlugin) waitForReady(ctx context.Context) error { + maxRetries := 30 + interval := 1 * time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("MinIO is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for MinIO") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("MinIO did not become ready after %d attempts", maxRetries)) +} + +// mustParsePort parses port string to int, panics on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/minio/minio_test.go b/internal/plugin/services/minio/minio_test.go new file mode 100644 index 0000000..784d4ff --- /dev/null +++ b/internal/plugin/services/minio/minio_test.go @@ -0,0 +1,254 @@ +package minio + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewMinIOPlugin(t *testing.T) { + logger := zap.NewNop() + plugin := NewMinIOPlugin(nil, logger) + + assert.NotNil(t, plugin) + assert.Equal(t, "minio", plugin.Name()) +} + +func TestName(t *testing.T) { + plugin := NewMinIOPlugin(nil, nil) + assert.Equal(t, "minio", plugin.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want *MinIOConfig + wantErr bool + errContains string + }{ + { + name: "default config", + input: map[string]interface{}{}, + want: &MinIOConfig{ + Image: defaultImage, + Port: defaultPort, + ConsolePort: defaultConsolePort, + AccessKey: defaultAccessKey, + SecretKey: defaultSecretKey, + }, + wantErr: false, + }, + { + name: "custom config with string port", + input: map[string]interface{}{ + "image": "minio/minio:RELEASE.2024-01-01", + "port": "9100", + "console-port": "9101", + "access-key": "myaccess", + "secret-key": "mysecret", + }, + want: &MinIOConfig{ + Image: "minio/minio:RELEASE.2024-01-01", + Port: "9100", + ConsolePort: "9101", + AccessKey: "myaccess", + SecretKey: "mysecret", + }, + wantErr: false, + }, + { + name: "custom config with numeric port", + input: map[string]interface{}{ + "port": float64(9100), + "console-port": float64(9101), + }, + want: &MinIOConfig{ + Image: defaultImage, + Port: "9100", + ConsolePort: "9101", + AccessKey: defaultAccessKey, + SecretKey: defaultSecretKey, + }, + wantErr: false, + }, + { + name: "custom access and secret key", + input: map[string]interface{}{ + "access-key": "customaccess", + "secret-key": "customsecret", + }, + want: &MinIOConfig{ + Image: defaultImage, + Port: defaultPort, + ConsolePort: defaultConsolePort, + AccessKey: "customaccess", + SecretKey: "customsecret", + }, + wantErr: false, + }, + { + name: "with container name", + input: map[string]interface{}{ + "container-name": "my-minio", + }, + want: &MinIOConfig{ + Image: defaultImage, + Port: defaultPort, + ConsolePort: defaultConsolePort, + AccessKey: defaultAccessKey, + SecretKey: defaultSecretKey, + ContainerName: "my-minio", + }, + wantErr: false, + }, + { + name: "partial config", + input: map[string]interface{}{ + "port": "9200", + "access-key": "partialaccess", + }, + want: &MinIOConfig{ + Image: defaultImage, + Port: "9200", + ConsolePort: defaultConsolePort, + AccessKey: "partialaccess", + SecretKey: defaultSecretKey, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewMinIOPlugin(nil, nil) + got, err := plugin.parseConfig(tt.input) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.Port, got.Port) + assert.Equal(t, tt.want.ConsolePort, got.ConsolePort) + assert.Equal(t, tt.want.AccessKey, got.AccessKey) + assert.Equal(t, tt.want.SecretKey, got.SecretKey) + if tt.want.ContainerName != "" { + assert.Equal(t, tt.want.ContainerName, got.ContainerName) + } else { + // Container name should be auto-generated + assert.NotEmpty(t, got.ContainerName) + assert.Contains(t, got.ContainerName, containerNamePrefix) + } + }) + } +} + +func TestGetConnectionInfo(t *testing.T) { + tests := []struct { + name string + config *MinIOConfig + wantErr bool + errContains string + }{ + { + name: "valid config", + config: &MinIOConfig{ + Port: "9000", + ConsolePort: "9001", + AccessKey: "testaccess", + SecretKey: "testsecret", + }, + wantErr: false, + }, + { + name: "no config", + config: nil, + wantErr: true, + errContains: "not launched", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewMinIOPlugin(nil, nil) + plugin.config = tt.config + + got, err := plugin.GetConnectionInfo() + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, mustParsePort(tt.config.Port), got.Port) + assert.Equal(t, "s3", got.Protocol) + assert.Equal(t, tt.config.AccessKey, got.Metadata["accessKey"]) + assert.Equal(t, tt.config.SecretKey, got.Metadata["secretKey"]) + assert.Equal(t, "http://localhost:"+tt.config.Port, got.Metadata["endpoint"]) + assert.Equal(t, tt.config.ConsolePort, got.Metadata["consolePort"]) + }) + } +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + { + name: "standard port", + input: "9000", + want: 9000, + }, + { + name: "custom port", + input: "9100", + want: 9100, + }, + { + name: "zero returns zero", + input: "0", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mustParsePort(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsReady_NotStarted(t *testing.T) { + plugin := NewMinIOPlugin(nil, nil) + + // Should return error when container not started + ready, err := plugin.IsReady(nil) + assert.False(t, ready) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + plugin := NewMinIOPlugin(nil, zap.NewNop()) + + // Should not error when no container to stop + err := plugin.Stop(nil) + assert.NoError(t, err) +} diff --git a/internal/plugin/services/mongodb/mongodb.go b/internal/plugin/services/mongodb/mongodb.go new file mode 100644 index 0000000..6cb66fd --- /dev/null +++ b/internal/plugin/services/mongodb/mongodb.go @@ -0,0 +1,355 @@ +package mongodb + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "mongo:7" + defaultPort = "27017" + defaultDatabase = "test" + containerNamePrefix = "gtool-mongodb" +) + +// MongoDBPlugin implements the ServicePlugin interface for MongoDB +type MongoDBPlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *MongoDBConfig +} + +// MongoDBConfig holds MongoDB-specific configuration +type MongoDBConfig struct { + Image string `json:"image"` + Port string `json:"port"` + Database string `json:"database"` + ContainerName string `json:"container-name"` +} + +// NewMongoDBPlugin creates a new MongoDB service plugin +func NewMongoDBPlugin(dockerClient *docker.Client, logger *zap.Logger) *MongoDBPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &MongoDBPlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *MongoDBPlugin) Name() string { + return "mongodb" +} + +// Launch starts the MongoDB service with given configuration +func (p *MongoDBPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching MongoDB service") + + // Parse configuration + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse MongoDB configuration") + } + p.config = cfg + + // Pull image + p.logger.Info("pulling MongoDB image", zap.String("image", cfg.Image)) + if err := p.docker.EnsureImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull MongoDB image") + } + + // Create container + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + PortBindings: map[string]string{ + "27017": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "mongodb", + }, + } + + p.logger.Info("creating MongoDB container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create MongoDB container") + } + p.containerID = containerID + + // Start container + p.logger.Info("starting MongoDB container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start MongoDB container") + } + + // Wait for MongoDB to be ready + p.logger.Info("waiting for MongoDB to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "MongoDB did not become ready") + } + + p.logger.Info("MongoDB service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the MongoDB service is ready to accept connections +func (p *MongoDBPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "MongoDB container not started") + } + + // Check if container is running + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Check if MongoDB is ready by pinging via mongosh + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{"mongosh", "--quiet", "--eval", "db.runCommand({ ping: 1 })"}, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Debug("MongoDB not ready yet", zap.String("output", output)) + return false, nil + } + + return true, nil +} + +// Stop terminates the MongoDB service +func (p *MongoDBPlugin) Stop(ctx context.Context) error { + // If no containerID, try to find container by labels + if p.containerID == "" { + // If no Docker client, nothing to stop + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "mongodb", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list MongoDB containers") + } + + if len(containers) == 0 { + p.logger.Warn("no MongoDB containers found to stop") + return nil + } + + // Stop all matching containers + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found MongoDB container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *MongoDBPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping MongoDB service", zap.String("containerID", p.containerID)) + + // Stop container + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + // Remove container + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove MongoDB container") + } + + p.logger.Info("MongoDB service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *MongoDBPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "MongoDB service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "mongodb", + Metadata: map[string]string{ + "database": p.config.Database, + "connectionString": fmt.Sprintf("mongodb://localhost:%s", p.config.Port), + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *MongoDBPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + // If no containerID, try to find container by labels + if containerID == "" { + // If no Docker client, cannot get logs + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "mongodb", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list MongoDB containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "MongoDB container not found") + } + + // Find first running container + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + // Fallback to first container if none are running + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found MongoDB container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + // Split logs into lines + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into MongoDBConfig +func (p *MongoDBPlugin) parseConfig(config map[string]interface{}) (*MongoDBConfig, error) { + cfg := &MongoDBConfig{ + Image: defaultImage, + Port: defaultPort, + Database: defaultDatabase, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + // Override with provided values + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if database, ok := config["database"].(string); ok && database != "" { + cfg.Database = database + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for MongoDB to be ready +func (p *MongoDBPlugin) waitForReady(ctx context.Context) error { + maxRetries := 60 + interval := 2 * time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("MongoDB is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for MongoDB") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("MongoDB did not become ready after %d attempts", maxRetries)) +} + +// mustParsePort parses port string to int, panics on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/mongodb/mongodb_test.go b/internal/plugin/services/mongodb/mongodb_test.go new file mode 100644 index 0000000..97140a0 --- /dev/null +++ b/internal/plugin/services/mongodb/mongodb_test.go @@ -0,0 +1,231 @@ +package mongodb + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewMongoDBPlugin(t *testing.T) { + logger := zap.NewNop() + plugin := NewMongoDBPlugin(nil, logger) + + assert.NotNil(t, plugin) + assert.Equal(t, "mongodb", plugin.Name()) +} + +func TestName(t *testing.T) { + plugin := NewMongoDBPlugin(nil, nil) + assert.Equal(t, "mongodb", plugin.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want *MongoDBConfig + wantErr bool + errContains string + }{ + { + name: "default config", + input: map[string]interface{}{}, + want: &MongoDBConfig{ + Image: defaultImage, + Port: defaultPort, + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "custom config with string port", + input: map[string]interface{}{ + "image": "mongo:6", + "port": "27018", + "database": "mydb", + }, + want: &MongoDBConfig{ + Image: "mongo:6", + Port: "27018", + Database: "mydb", + }, + wantErr: false, + }, + { + name: "custom config with numeric port", + input: map[string]interface{}{ + "port": float64(27018), + }, + want: &MongoDBConfig{ + Image: defaultImage, + Port: "27018", + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "with database", + input: map[string]interface{}{ + "database": "customdb", + }, + want: &MongoDBConfig{ + Image: defaultImage, + Port: defaultPort, + Database: "customdb", + }, + wantErr: false, + }, + { + name: "with container name", + input: map[string]interface{}{ + "container-name": "my-mongodb", + }, + want: &MongoDBConfig{ + Image: defaultImage, + Port: defaultPort, + Database: defaultDatabase, + ContainerName: "my-mongodb", + }, + wantErr: false, + }, + { + name: "partial config", + input: map[string]interface{}{ + "image": "mongo:5", + }, + want: &MongoDBConfig{ + Image: "mongo:5", + Port: defaultPort, + Database: defaultDatabase, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewMongoDBPlugin(nil, nil) + got, err := plugin.parseConfig(tt.input) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.Port, got.Port) + assert.Equal(t, tt.want.Database, got.Database) + if tt.want.ContainerName != "" { + assert.Equal(t, tt.want.ContainerName, got.ContainerName) + } else { + // Container name should be auto-generated + assert.NotEmpty(t, got.ContainerName) + assert.Contains(t, got.ContainerName, containerNamePrefix) + } + }) + } +} + +func TestGetConnectionInfo(t *testing.T) { + tests := []struct { + name string + config *MongoDBConfig + wantErr bool + errContains string + }{ + { + name: "valid config", + config: &MongoDBConfig{ + Port: "27017", + Database: "testdb", + }, + wantErr: false, + }, + { + name: "no config", + config: nil, + wantErr: true, + errContains: "not launched", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewMongoDBPlugin(nil, nil) + plugin.config = tt.config + + got, err := plugin.GetConnectionInfo() + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, mustParsePort(tt.config.Port), got.Port) + assert.Equal(t, "mongodb", got.Protocol) + assert.Equal(t, tt.config.Database, got.Metadata["database"]) + assert.Equal(t, "mongodb://localhost:"+tt.config.Port, got.Metadata["connectionString"]) + }) + } +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + { + name: "standard port", + input: "27017", + want: 27017, + }, + { + name: "custom port", + input: "27018", + want: 27018, + }, + { + name: "zero returns zero", + input: "0", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mustParsePort(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsReady_NotStarted(t *testing.T) { + plugin := NewMongoDBPlugin(nil, nil) + + // Should return error when container not started + ready, err := plugin.IsReady(nil) + assert.False(t, ready) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + plugin := NewMongoDBPlugin(nil, zap.NewNop()) + + // Should not error when no container to stop + err := plugin.Stop(nil) + assert.NoError(t, err) +} diff --git a/internal/plugin/services/mountebank/integration_test.go b/internal/plugin/services/mountebank/integration_test.go new file mode 100644 index 0000000..346d957 --- /dev/null +++ b/internal/plugin/services/mountebank/integration_test.go @@ -0,0 +1,87 @@ +//go:build integration +// +build integration + +package mountebank + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +func TestMountebankIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := logger.Default() + defer log.Sync() + + dockerClient, err := docker.NewClient(log.Logger) + require.NoError(t, err, "Failed to create Docker client") + defer dockerClient.Close() + + ctx := context.Background() + err = dockerClient.Ping(ctx) + require.NoError(t, err, "Docker daemon not available") + + plugin := NewMountebankPlugin(dockerClient, log.Logger) + require.NotNil(t, plugin) + + const port = "12525" + config := map[string]interface{}{ + "image": defaultImage, + "port": port, + "imposters-path": "../../../../test/component/mocks-data/mountebank", + } + + log.Info("launching Mountebank for integration test") + err = plugin.Launch(ctx, config) + require.NoError(t, err, "Failed to launch Mountebank") + + defer func() { + log.Info("cleaning up Mountebank container") + if err := plugin.Stop(ctx); err != nil { + t.Logf("Failed to stop Mountebank: %v", err) + } + }() + + t.Run("IsReady", func(t *testing.T) { + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "Mountebank should be ready") + }) + + t.Run("GetConnectionInfo", func(t *testing.T) { + connInfo, err := plugin.GetConnectionInfo() + require.NoError(t, err) + assert.Equal(t, "localhost", connInfo.Host) + assert.Equal(t, 12525, connInfo.Port) + assert.Equal(t, "http", connInfo.Protocol) + }) + + t.Run("ImposterLoaded", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/imposters/4545", port)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "imposter 4545 should be registered") + }) + + t.Run("GetLogs", func(t *testing.T) { + logs, err := plugin.GetLogs(ctx, nil) + require.NoError(t, err) + assert.NotEmpty(t, logs, "Should have logs") + }) + + t.Run("Stop", func(t *testing.T) { + err := plugin.Stop(ctx) + require.NoError(t, err, "Failed to stop Mountebank") + }) +} diff --git a/internal/plugin/services/mountebank/mountebank.go b/internal/plugin/services/mountebank/mountebank.go new file mode 100644 index 0000000..034d921 --- /dev/null +++ b/internal/plugin/services/mountebank/mountebank.go @@ -0,0 +1,423 @@ +package mountebank + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "bbyars/mountebank:2.9.1" + defaultPort = "2525" + adminPort = "2525" + containerNamePrefix = "gtool-mountebank" +) + +// MountebankPlugin implements the ServicePlugin interface for Mountebank, +// a service virtualization tool used to mock HTTP/TCP dependencies. +type MountebankPlugin struct { + docker *docker.Client + logger *zap.Logger + httpClient *http.Client + containerID string + config *MountebankConfig +} + +// MountebankConfig holds Mountebank-specific configuration +type MountebankConfig struct { + Image string `json:"image"` + Port string `json:"port"` + ImpostersPath string `json:"imposters-path"` + ContainerName string `json:"container-name"` +} + +// NewMountebankPlugin creates a new Mountebank service plugin +func NewMountebankPlugin(dockerClient *docker.Client, logger *zap.Logger) *MountebankPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &MountebankPlugin{ + docker: dockerClient, + logger: logger, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Name returns the service identifier +func (p *MountebankPlugin) Name() string { + return "mountebank" +} + +// Launch starts the Mountebank service with given configuration +func (p *MountebankPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching Mountebank service") + + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse Mountebank configuration") + } + p.config = cfg + + p.logger.Info("pulling Mountebank image", zap.String("image", cfg.Image)) + if err := p.docker.EnsureImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull Mountebank image") + } + + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + PortBindings: map[string]string{ + adminPort: cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "mountebank", + }, + } + + p.logger.Info("creating Mountebank container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create Mountebank container") + } + p.containerID = containerID + + p.logger.Info("starting Mountebank container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start Mountebank container") + } + + p.logger.Info("waiting for Mountebank to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "Mountebank did not become ready") + } + + // Load imposters if a path is provided + if cfg.ImpostersPath != "" { + p.logger.Info("loading imposters", zap.String("path", cfg.ImpostersPath)) + if err := p.loadImposters(ctx, cfg.ImpostersPath); err != nil { + // Don't fail the launch if imposters fail, just log the error + p.logger.Error("failed to load imposters", + zap.Error(err), + zap.String("path", cfg.ImpostersPath)) + } + } + + p.logger.Info("Mountebank service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the Mountebank admin API is accepting requests +func (p *MountebankPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "Mountebank container not started") + } + + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Probe the admin API root; a 200 means Mountebank is serving requests + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.adminURL(), nil) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build readiness request") + } + + resp, err := p.httpClient.Do(req) + if err != nil { + p.logger.Debug("Mountebank not ready yet", zap.Error(err)) + return false, nil + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK, nil +} + +// Stop terminates the Mountebank service +func (p *MountebankPlugin) Stop(ctx context.Context) error { + if p.containerID == "" { + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "mountebank", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Mountebank containers") + } + + if len(containers) == 0 { + p.logger.Warn("no Mountebank containers found to stop") + return nil + } + + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found Mountebank container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *MountebankPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping Mountebank service", zap.String("containerID", p.containerID)) + + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove Mountebank container") + } + + p.logger.Info("Mountebank service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *MountebankPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Mountebank service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "http", + Metadata: map[string]string{ + "admin-url": p.adminURL(), + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *MountebankPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + if containerID == "" { + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "mountebank", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Mountebank containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Mountebank container not found") + } + + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found Mountebank container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into MountebankConfig +func (p *MountebankPlugin) parseConfig(config map[string]interface{}) (*MountebankConfig, error) { + cfg := &MountebankConfig{ + Image: defaultImage, + Port: defaultPort, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if impostersPath, ok := config["imposters-path"].(string); ok && impostersPath != "" { + cfg.ImpostersPath = impostersPath + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for Mountebank to be ready +func (p *MountebankPlugin) waitForReady(ctx context.Context) error { + maxRetries := 30 + interval := time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("Mountebank is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for Mountebank") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("Mountebank did not become ready after %d attempts", maxRetries)) +} + +// loadImposters posts every imposter definition found in the given directory +// to the Mountebank admin API. +func (p *MountebankPlugin) loadImposters(ctx context.Context, impostersPath string) error { + if _, err := os.Stat(impostersPath); os.IsNotExist(err) { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, + fmt.Sprintf("imposters path does not exist: %s", impostersPath)) + } + + files, err := filepath.Glob(filepath.Join(impostersPath, "*.json")) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to list imposter files") + } + + if len(files) == 0 { + p.logger.Warn("no imposter files found in path", zap.String("path", impostersPath)) + return nil + } + + p.logger.Info("found imposter files to load", + zap.Int("count", len(files)), + zap.Strings("files", files)) + + for _, file := range files { + if err := p.postImposter(ctx, file); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to load imposter: %s", file)) + } + } + + return nil +} + +// postImposter reads an imposter definition file and posts it to the admin API +func (p *MountebankPlugin) postImposter(ctx context.Context, path string) error { + p.logger.Info("loading imposter", zap.String("file", path)) + + content, err := os.ReadFile(path) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to read imposter file") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + p.adminURL()+"imposters", bytes.NewReader(content)) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build imposter request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to post imposter") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("unexpected status posting imposter %s: %d", path, resp.StatusCode)) + } + + p.logger.Info("imposter loaded successfully", zap.String("file", path)) + return nil +} + +// adminURL builds the base URL of the Mountebank admin API +func (p *MountebankPlugin) adminURL() string { + return fmt.Sprintf("http://localhost:%s/", p.config.Port) +} + +// mustParsePort parses port string to int, returns 0 on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/mountebank/mountebank_test.go b/internal/plugin/services/mountebank/mountebank_test.go new file mode 100644 index 0000000..0058631 --- /dev/null +++ b/internal/plugin/services/mountebank/mountebank_test.go @@ -0,0 +1,155 @@ +package mountebank + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewMountebankPlugin(t *testing.T) { + p := NewMountebankPlugin(nil, zap.NewNop()) + + assert.NotNil(t, p) + assert.Equal(t, "mountebank", p.Name()) + assert.NotNil(t, p.httpClient) +} + +func TestName(t *testing.T) { + p := NewMountebankPlugin(nil, nil) + assert.Equal(t, "mountebank", p.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want *MountebankConfig + }{ + { + name: "default config", + input: map[string]interface{}{}, + want: &MountebankConfig{ + Image: defaultImage, + Port: defaultPort, + }, + }, + { + name: "custom config with string port", + input: map[string]interface{}{ + "image": "bbyars/mountebank:2.8.0", + "port": "3000", + "imposters-path": "/path/to/imposters", + }, + want: &MountebankConfig{ + Image: "bbyars/mountebank:2.8.0", + Port: "3000", + ImpostersPath: "/path/to/imposters", + }, + }, + { + name: "numeric port", + input: map[string]interface{}{ + "port": float64(3000), + }, + want: &MountebankConfig{ + Image: defaultImage, + Port: "3000", + }, + }, + { + name: "custom container name", + input: map[string]interface{}{ + "container-name": "my-mb", + }, + want: &MountebankConfig{ + Image: defaultImage, + Port: defaultPort, + ContainerName: "my-mb", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewMountebankPlugin(nil, nil) + got, err := p.parseConfig(tt.input) + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.Port, got.Port) + assert.Equal(t, tt.want.ImpostersPath, got.ImpostersPath) + if tt.want.ContainerName != "" { + assert.Equal(t, tt.want.ContainerName, got.ContainerName) + } else { + assert.Contains(t, got.ContainerName, containerNamePrefix) + } + }) + } +} + +func TestGetConnectionInfo(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + p := NewMountebankPlugin(nil, nil) + p.config = &MountebankConfig{Port: "2525"} + + got, err := p.GetConnectionInfo() + + require.NoError(t, err) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, 2525, got.Port) + assert.Equal(t, "http", got.Protocol) + assert.Equal(t, "http://localhost:2525/", got.Metadata["admin-url"]) + }) + + t.Run("not launched", func(t *testing.T) { + p := NewMountebankPlugin(nil, nil) + + _, err := p.GetConnectionInfo() + + require.Error(t, err) + assert.Contains(t, err.Error(), "not launched") + }) +} + +func TestAdminURL(t *testing.T) { + p := NewMountebankPlugin(nil, nil) + p.config = &MountebankConfig{Port: "3000"} + + assert.Equal(t, "http://localhost:3000/", p.adminURL()) +} + +func TestIsReady_NotStarted(t *testing.T) { + p := NewMountebankPlugin(nil, nil) + + ready, err := p.IsReady(nil) + + assert.False(t, ready) + require.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + p := NewMountebankPlugin(nil, zap.NewNop()) + + err := p.Stop(nil) + assert.NoError(t, err) +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"2525", 2525}, + {"3000", 3000}, + {"0", 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, mustParsePort(tt.input)) + }) + } +} diff --git a/internal/plugin/services/mysql/mysql.go b/internal/plugin/services/mysql/mysql.go new file mode 100644 index 0000000..e005d4e --- /dev/null +++ b/internal/plugin/services/mysql/mysql.go @@ -0,0 +1,390 @@ +package mysql + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "mysql:8.4" + defaultPort = "3306" + defaultRootPassword = "root" + defaultDatabase = "test" + containerNamePrefix = "gtool-mysql" +) + +// MySQLPlugin implements the ServicePlugin interface for MySQL +type MySQLPlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *MySQLConfig +} + +// MySQLConfig holds MySQL-specific configuration +type MySQLConfig struct { + Image string `json:"image"` + Port string `json:"port"` + RootPassword string `json:"root-password"` + Database string `json:"database"` + User string `json:"user"` + Password string `json:"password"` + ContainerName string `json:"container-name"` +} + +// NewMySQLPlugin creates a new MySQL service plugin +func NewMySQLPlugin(dockerClient *docker.Client, logger *zap.Logger) *MySQLPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &MySQLPlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *MySQLPlugin) Name() string { + return "mysql" +} + +// Launch starts the MySQL service with given configuration +func (p *MySQLPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching MySQL service") + + // Parse configuration + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse MySQL configuration") + } + p.config = cfg + + // Pull image + p.logger.Info("pulling MySQL image", zap.String("image", cfg.Image)) + if err := p.docker.EnsureImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull MySQL image") + } + + // Build environment + env := []string{ + fmt.Sprintf("MYSQL_ROOT_PASSWORD=%s", cfg.RootPassword), + fmt.Sprintf("MYSQL_DATABASE=%s", cfg.Database), + } + if cfg.User != "" { + env = append(env, + fmt.Sprintf("MYSQL_USER=%s", cfg.User), + fmt.Sprintf("MYSQL_PASSWORD=%s", cfg.Password), + ) + } + + // Create container + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + Env: env, + PortBindings: map[string]string{ + "3306": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "mysql", + }, + } + + p.logger.Info("creating MySQL container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create MySQL container") + } + p.containerID = containerID + + // Start container + p.logger.Info("starting MySQL container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start MySQL container") + } + + // Wait for MySQL to be ready + p.logger.Info("waiting for MySQL to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "MySQL did not become ready") + } + + p.logger.Info("MySQL service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the MySQL service is ready to accept connections +func (p *MySQLPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "MySQL container not started") + } + + // Check if container is running + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Check if MySQL is ready by executing mysqladmin ping + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{"mysqladmin", "ping", "-uroot", fmt.Sprintf("--password=%s", p.config.RootPassword), "--silent"}, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Debug("MySQL not ready yet", zap.String("output", output)) + return false, nil + } + + return true, nil +} + +// Stop terminates the MySQL service +func (p *MySQLPlugin) Stop(ctx context.Context) error { + // If no containerID, try to find container by labels + if p.containerID == "" { + // If no Docker client, nothing to stop + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "mysql", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list MySQL containers") + } + + if len(containers) == 0 { + p.logger.Warn("no MySQL containers found to stop") + return nil + } + + // Stop all matching containers + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found MySQL container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *MySQLPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping MySQL service", zap.String("containerID", p.containerID)) + + // Stop container + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + // Remove container + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove MySQL container") + } + + p.logger.Info("MySQL service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *MySQLPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "MySQL service not launched") + } + + user := "root" + password := p.config.RootPassword + if p.config.User != "" { + user = p.config.User + password = p.config.Password + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "mysql", + Metadata: map[string]string{ + "user": user, + "password": password, + "database": p.config.Database, + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *MySQLPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + // If no containerID, try to find container by labels + if containerID == "" { + // If no Docker client, cannot get logs + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "mysql", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list MySQL containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "MySQL container not found") + } + + // Find first running container + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + // Fallback to first container if none are running + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found MySQL container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + // Split logs into lines + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into MySQLConfig +func (p *MySQLPlugin) parseConfig(config map[string]interface{}) (*MySQLConfig, error) { + cfg := &MySQLConfig{ + Image: defaultImage, + Port: defaultPort, + RootPassword: defaultRootPassword, + Database: defaultDatabase, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + // Override with provided values + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if rootPassword, ok := config["root-password"].(string); ok && rootPassword != "" { + cfg.RootPassword = rootPassword + } + if database, ok := config["database"].(string); ok && database != "" { + cfg.Database = database + } + if user, ok := config["user"].(string); ok && user != "" { + cfg.User = user + } + if password, ok := config["password"].(string); ok && password != "" { + cfg.Password = password + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for MySQL to be ready +func (p *MySQLPlugin) waitForReady(ctx context.Context) error { + maxRetries := 60 + interval := 2 * time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("MySQL is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for MySQL") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("MySQL did not become ready after %d attempts", maxRetries)) +} + +// mustParsePort parses port string to int, panics on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/mysql/mysql_test.go b/internal/plugin/services/mysql/mysql_test.go new file mode 100644 index 0000000..61165cd --- /dev/null +++ b/internal/plugin/services/mysql/mysql_test.go @@ -0,0 +1,264 @@ +package mysql + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewMySQLPlugin(t *testing.T) { + logger := zap.NewNop() + plugin := NewMySQLPlugin(nil, logger) + + assert.NotNil(t, plugin) + assert.Equal(t, "mysql", plugin.Name()) +} + +func TestName(t *testing.T) { + plugin := NewMySQLPlugin(nil, nil) + assert.Equal(t, "mysql", plugin.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want *MySQLConfig + wantErr bool + errContains string + }{ + { + name: "default config", + input: map[string]interface{}{}, + want: &MySQLConfig{ + Image: defaultImage, + Port: defaultPort, + RootPassword: defaultRootPassword, + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "custom config with string port", + input: map[string]interface{}{ + "image": "mysql:8.0", + "port": "3307", + "root-password": "secret", + "database": "mydb", + }, + want: &MySQLConfig{ + Image: "mysql:8.0", + Port: "3307", + RootPassword: "secret", + Database: "mydb", + }, + wantErr: false, + }, + { + name: "custom config with numeric port", + input: map[string]interface{}{ + "port": float64(3307), + }, + want: &MySQLConfig{ + Image: defaultImage, + Port: "3307", + RootPassword: defaultRootPassword, + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "with user and password", + input: map[string]interface{}{ + "user": "appuser", + "password": "apppass", + }, + want: &MySQLConfig{ + Image: defaultImage, + Port: defaultPort, + RootPassword: defaultRootPassword, + Database: defaultDatabase, + User: "appuser", + Password: "apppass", + }, + wantErr: false, + }, + { + name: "with container name", + input: map[string]interface{}{ + "container-name": "my-mysql", + }, + want: &MySQLConfig{ + Image: defaultImage, + Port: defaultPort, + RootPassword: defaultRootPassword, + Database: defaultDatabase, + ContainerName: "my-mysql", + }, + wantErr: false, + }, + { + name: "partial config", + input: map[string]interface{}{ + "root-password": "custompass", + "database": "customdb", + }, + want: &MySQLConfig{ + Image: defaultImage, + Port: defaultPort, + RootPassword: "custompass", + Database: "customdb", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewMySQLPlugin(nil, nil) + got, err := plugin.parseConfig(tt.input) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.Port, got.Port) + assert.Equal(t, tt.want.RootPassword, got.RootPassword) + assert.Equal(t, tt.want.Database, got.Database) + assert.Equal(t, tt.want.User, got.User) + assert.Equal(t, tt.want.Password, got.Password) + if tt.want.ContainerName != "" { + assert.Equal(t, tt.want.ContainerName, got.ContainerName) + } else { + // Container name should be auto-generated + assert.NotEmpty(t, got.ContainerName) + assert.Contains(t, got.ContainerName, containerNamePrefix) + } + }) + } +} + +func TestGetConnectionInfo(t *testing.T) { + tests := []struct { + name string + config *MySQLConfig + wantUser string + wantPassword string + wantErr bool + errContains string + }{ + { + name: "valid config root only", + config: &MySQLConfig{ + Port: "3306", + RootPassword: "rootpass", + Database: "testdb", + }, + wantUser: "root", + wantPassword: "rootpass", + wantErr: false, + }, + { + name: "valid config with user", + config: &MySQLConfig{ + Port: "3306", + RootPassword: "rootpass", + Database: "testdb", + User: "appuser", + Password: "apppass", + }, + wantUser: "appuser", + wantPassword: "apppass", + wantErr: false, + }, + { + name: "no config", + config: nil, + wantErr: true, + errContains: "not launched", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewMySQLPlugin(nil, nil) + plugin.config = tt.config + + got, err := plugin.GetConnectionInfo() + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, mustParsePort(tt.config.Port), got.Port) + assert.Equal(t, "mysql", got.Protocol) + assert.Equal(t, tt.wantUser, got.Metadata["user"]) + assert.Equal(t, tt.wantPassword, got.Metadata["password"]) + assert.Equal(t, tt.config.Database, got.Metadata["database"]) + }) + } +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + { + name: "standard port", + input: "3306", + want: 3306, + }, + { + name: "custom port", + input: "3307", + want: 3307, + }, + { + name: "zero returns zero", + input: "0", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mustParsePort(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsReady_NotStarted(t *testing.T) { + plugin := NewMySQLPlugin(nil, nil) + + // Should return error when container not started + ready, err := plugin.IsReady(nil) + assert.False(t, ready) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + plugin := NewMySQLPlugin(nil, zap.NewNop()) + + // Should not error when no container to stop + err := plugin.Stop(nil) + assert.NoError(t, err) +} diff --git a/internal/plugin/services/postgresql/README.md b/internal/plugin/services/postgresql/README.md new file mode 100644 index 0000000..61b06b6 --- /dev/null +++ b/internal/plugin/services/postgresql/README.md @@ -0,0 +1,50 @@ +# PostgreSQL Service Plugin + +> **Documentation has moved!** +> +> For complete PostgreSQL plugin documentation, please visit: +> **[docs/services/postgresql/](../../../../docs/services/postgresql/)** + +## Quick Links + +- **[Quick Start Guide](../../../../docs/services/postgresql/quickstart.md)** - Get started in 5 minutes +- **[Plugin Documentation](../../../../docs/services/postgresql/README.md)** - Complete API reference +- **[Implementation Details](../../../../docs/services/postgresql/implementation.md)** - Technical architecture +- **[SQL Scripts Guide](../../../../docs/services/postgresql/sql-scripts.md)** - How to create SQL scripts + +## Quick Example + +```go +import ( + "context" + "github.com/oswaldo-montano/gtool/internal/infra/docker" + "github.com/oswaldo-montano/gtool/internal/plugin/services/postgresql" +) + +// Create plugin +dockerClient, _ := docker.NewClient(logger) +plugin := postgresql.NewPostgreSQLPlugin(dockerClient, logger) + +// Launch PostgreSQL +ctx := context.Background() +config := map[string]interface{}{ + "port": "5432", + "scripts-path": "./test/component/mocks-data/postgresql", +} +plugin.Launch(ctx, config) +defer plugin.Stop(ctx) +``` + +## Configuration + +```yaml +third-party: + mocks: + - postgresql + mock-config: + postgresql: + port: 5432 + scripts-path: ./test/component/mocks-data/postgresql +``` + +For more details, see the [full documentation](../../../../docs/services/postgresql/). diff --git a/internal/plugin/services/postgresql/integration_test.go b/internal/plugin/services/postgresql/integration_test.go new file mode 100644 index 0000000..9f38063 --- /dev/null +++ b/internal/plugin/services/postgresql/integration_test.go @@ -0,0 +1,163 @@ +//go:build integration +// +build integration + +package postgresql + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +func TestPostgreSQLIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := logger.Default() + defer log.Sync() + + dockerClient, err := docker.NewClient(log.Logger) + require.NoError(t, err, "Failed to create Docker client") + defer dockerClient.Close() + + ctx := context.Background() + err = dockerClient.Ping(ctx) + require.NoError(t, err, "Docker daemon not available") + + plugin := NewPostgreSQLPlugin(dockerClient, log.Logger) + require.NotNil(t, plugin) + + config := map[string]interface{}{ + "image": "postgres:16-alpine", + "port": "15432", + "user": "integrationtest", + "password": "testpass123", + "database": "testdb", + "scripts-path": "../../../../test/component/mocks-data/postgresql", + } + + log.Info("launching PostgreSQL for integration test") + err = plugin.Launch(ctx, config) + require.NoError(t, err, "Failed to launch PostgreSQL") + + defer func() { + log.Info("cleaning up PostgreSQL container") + if err := plugin.Stop(ctx); err != nil { + t.Logf("Failed to stop PostgreSQL: %v", err) + } + }() + + t.Run("IsReady", func(t *testing.T) { + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "PostgreSQL should be ready") + }) + + t.Run("GetConnectionInfo", func(t *testing.T) { + connInfo, err := plugin.GetConnectionInfo() + require.NoError(t, err) + assert.NotNil(t, connInfo) + assert.Equal(t, "localhost", connInfo.Host) + assert.Equal(t, 15432, connInfo.Port) + assert.Equal(t, "postgresql", connInfo.Protocol) + assert.Equal(t, "integrationtest", connInfo.Metadata["user"]) + assert.Equal(t, "testpass123", connInfo.Metadata["password"]) + assert.Equal(t, "testdb", connInfo.Metadata["database"]) + }) + + t.Run("GetLogs", func(t *testing.T) { + logs, err := plugin.GetLogs(ctx, nil) + require.NoError(t, err) + assert.NotEmpty(t, logs, "Should have logs") + }) + + t.Run("VerifyScripts", func(t *testing.T) { + time.Sleep(2 * time.Second) + + output, err := dockerClient.ExecInContainer(ctx, plugin.containerID, &docker.ExecConfig{ + Cmd: []string{ + "psql", + "-U", "integrationtest", + "-d", "testdb", + "-c", "SELECT COUNT(*) FROM users;", + }, + AttachStdout: true, + AttachStderr: true, + Env: []string{ + "PGPASSWORD=testpass123", + }, + }) + require.NoError(t, err, "Failed to query users table: %s", output) + assert.Contains(t, output, "2", "Should have 2 users from init script") + + output, err = dockerClient.ExecInContainer(ctx, plugin.containerID, &docker.ExecConfig{ + Cmd: []string{ + "psql", + "-U", "integrationtest", + "-d", "testdb", + "-c", "SELECT COUNT(*) FROM products;", + }, + AttachStdout: true, + AttachStderr: true, + Env: []string{ + "PGPASSWORD=testpass123", + }, + }) + require.NoError(t, err, "Failed to query products table: %s", output) + assert.Contains(t, output, "2", "Should have 2 products from init script") + }) + + t.Run("Stop", func(t *testing.T) { + err := plugin.Stop(ctx) + require.NoError(t, err, "Failed to stop PostgreSQL") + + running, err := dockerClient.IsContainerRunning(ctx, plugin.containerID) + if plugin.containerID != "" { + require.Error(t, err) + } + assert.False(t, running, "Container should not be running") + }) +} + +func TestPostgreSQLIntegrationWithoutScripts(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := zap.NewNop() + + dockerClient, err := docker.NewClient(log) + require.NoError(t, err) + defer dockerClient.Close() + + ctx := context.Background() + + plugin := NewPostgreSQLPlugin(dockerClient, log) + + config := map[string]interface{}{ + "port": "25432", + "user": "testuser", + "password": "testpass", + "database": "testdb", + } + + err = plugin.Launch(ctx, config) + require.NoError(t, err) + + defer plugin.Stop(ctx) + + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready) + + err = plugin.Stop(ctx) + require.NoError(t, err) +} diff --git a/internal/plugin/services/postgresql/postgresql.go b/internal/plugin/services/postgresql/postgresql.go new file mode 100644 index 0000000..07d825b --- /dev/null +++ b/internal/plugin/services/postgresql/postgresql.go @@ -0,0 +1,467 @@ +package postgresql + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "postgres:16-alpine" + defaultPort = "5432" + defaultUser = "postgres" + defaultPassword = "postgres" + defaultDatabase = "postgres" + containerNamePrefix = "gtool-postgresql" +) + +// PostgreSQLPlugin implements the ServicePlugin interface for PostgreSQL +type PostgreSQLPlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *PostgreSQLConfig +} + +// PostgreSQLConfig holds PostgreSQL-specific configuration +type PostgreSQLConfig struct { + Image string `json:"image"` + Port string `json:"port"` + User string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` + ScriptsPath string `json:"scripts-path"` + ContainerName string `json:"container-name"` +} + +// NewPostgreSQLPlugin creates a new PostgreSQL service plugin +func NewPostgreSQLPlugin(dockerClient *docker.Client, logger *zap.Logger) *PostgreSQLPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &PostgreSQLPlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *PostgreSQLPlugin) Name() string { + return "postgresql" +} + +// Launch starts the PostgreSQL service with given configuration +func (p *PostgreSQLPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching PostgreSQL service") + + // Parse configuration + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse PostgreSQL configuration") + } + p.config = cfg + + // Pull image + p.logger.Info("pulling PostgreSQL image", zap.String("image", cfg.Image)) + if err := p.docker.EnsureImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull PostgreSQL image") + } + + // Create container + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + Env: []string{ + fmt.Sprintf("POSTGRES_USER=%s", cfg.User), + fmt.Sprintf("POSTGRES_PASSWORD=%s", cfg.Password), + fmt.Sprintf("POSTGRES_DB=%s", cfg.Database), + }, + PortBindings: map[string]string{ + "5432": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "postgresql", + }, + } + + p.logger.Info("creating PostgreSQL container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create PostgreSQL container") + } + p.containerID = containerID + + // Start container + p.logger.Info("starting PostgreSQL container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start PostgreSQL container") + } + + // Wait for PostgreSQL to be ready + p.logger.Info("waiting for PostgreSQL to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "PostgreSQL did not become ready") + } + + // Execute SQL scripts if provided + if cfg.ScriptsPath != "" { + p.logger.Info("executing SQL scripts", zap.String("path", cfg.ScriptsPath)) + if err := p.executeScripts(ctx, cfg.ScriptsPath); err != nil { + // Don't fail if scripts fail, just log the error + p.logger.Error("failed to execute SQL scripts", + zap.Error(err), + zap.String("path", cfg.ScriptsPath)) + } + } + + p.logger.Info("PostgreSQL service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the PostgreSQL service is ready to accept connections +func (p *PostgreSQLPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "PostgreSQL container not started") + } + + // Check if container is running + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Check if PostgreSQL is ready by executing pg_isready + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{"pg_isready", "-U", p.config.User, "-d", p.config.Database}, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Debug("PostgreSQL not ready yet", zap.String("output", output)) + return false, nil + } + + return true, nil +} + +// Stop terminates the PostgreSQL service +func (p *PostgreSQLPlugin) Stop(ctx context.Context) error { + // If no containerID, try to find container by labels + if p.containerID == "" { + // If no Docker client, nothing to stop + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "postgresql", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list PostgreSQL containers") + } + + if len(containers) == 0 { + p.logger.Warn("no PostgreSQL containers found to stop") + return nil + } + + // Stop all matching containers + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found PostgreSQL container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *PostgreSQLPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping PostgreSQL service", zap.String("containerID", p.containerID)) + + // Stop container + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + // Remove container + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove PostgreSQL container") + } + + p.logger.Info("PostgreSQL service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *PostgreSQLPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "PostgreSQL service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "postgresql", + Metadata: map[string]string{ + "user": p.config.User, + "password": p.config.Password, + "database": p.config.Database, + "sslmode": "disable", + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *PostgreSQLPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + // If no containerID, try to find container by labels + if containerID == "" { + // If no Docker client, cannot get logs + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "postgresql", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list PostgreSQL containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "PostgreSQL container not found") + } + + // Find first running container + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + // Fallback to first container if none are running + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found PostgreSQL container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + // Split logs into lines + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into PostgreSQLConfig +func (p *PostgreSQLPlugin) parseConfig(config map[string]interface{}) (*PostgreSQLConfig, error) { + cfg := &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + // Override with provided values + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if user, ok := config["user"].(string); ok && user != "" { + cfg.User = user + } + if password, ok := config["password"].(string); ok && password != "" { + cfg.Password = password + } + if database, ok := config["database"].(string); ok && database != "" { + cfg.Database = database + } + if scriptsPath, ok := config["scripts-path"].(string); ok && scriptsPath != "" { + cfg.ScriptsPath = scriptsPath + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for PostgreSQL to be ready +func (p *PostgreSQLPlugin) waitForReady(ctx context.Context) error { + maxRetries := 60 + interval := 2 * time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("PostgreSQL is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for PostgreSQL") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("PostgreSQL did not become ready after %d attempts", maxRetries)) +} + +// executeScripts executes SQL scripts from the specified directory +func (p *PostgreSQLPlugin) executeScripts(ctx context.Context, scriptsPath string) error { + // Check if scripts path exists + if _, err := os.Stat(scriptsPath); os.IsNotExist(err) { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, + fmt.Sprintf("scripts path does not exist: %s", scriptsPath)) + } + + // Get all .sql files + sqlFiles, err := filepath.Glob(filepath.Join(scriptsPath, "*.sql")) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to list SQL files") + } + + if len(sqlFiles) == 0 { + p.logger.Warn("no SQL files found in scripts path", zap.String("path", scriptsPath)) + return nil + } + + p.logger.Info("found SQL files to execute", + zap.Int("count", len(sqlFiles)), + zap.Strings("files", sqlFiles)) + + // Execute each SQL file + for _, sqlFile := range sqlFiles { + if err := p.executeScript(ctx, sqlFile); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to execute script: %s", sqlFile)) + } + } + + return nil +} + +// executeScript executes a single SQL script +func (p *PostgreSQLPlugin) executeScript(ctx context.Context, scriptPath string) error { + p.logger.Info("executing SQL script", zap.String("script", scriptPath)) + + // Read the script file + content, err := os.ReadFile(scriptPath) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to read SQL script") + } + + // Execute using psql + cmd := []string{ + "psql", + "-U", p.config.User, + "-d", p.config.Database, + "-c", string(content), + } + + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + Env: []string{ + fmt.Sprintf("PGPASSWORD=%s", p.config.Password), + }, + }) + + if err != nil { + p.logger.Error("failed to execute SQL script", + zap.Error(err), + zap.String("script", scriptPath), + zap.String("output", output)) + return err + } + + p.logger.Info("SQL script executed successfully", + zap.String("script", scriptPath), + zap.String("output", strings.TrimSpace(output))) + + return nil +} + +// mustParsePort parses port string to int, panics on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/postgresql/postgresql_test.go b/internal/plugin/services/postgresql/postgresql_test.go new file mode 100644 index 0000000..98e0d18 --- /dev/null +++ b/internal/plugin/services/postgresql/postgresql_test.go @@ -0,0 +1,254 @@ +package postgresql + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewPostgreSQLPlugin(t *testing.T) { + logger := zap.NewNop() + plugin := NewPostgreSQLPlugin(nil, logger) + + assert.NotNil(t, plugin) + assert.Equal(t, "postgresql", plugin.Name()) +} + +func TestName(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + assert.Equal(t, "postgresql", plugin.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want *PostgreSQLConfig + wantErr bool + errContains string + }{ + { + name: "default config", + input: map[string]interface{}{}, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "custom config with string port", + input: map[string]interface{}{ + "image": "postgres:15", + "port": "5433", + "user": "myuser", + "password": "mypass", + "database": "mydb", + }, + want: &PostgreSQLConfig{ + Image: "postgres:15", + Port: "5433", + User: "myuser", + Password: "mypass", + Database: "mydb", + }, + wantErr: false, + }, + { + name: "custom config with numeric port", + input: map[string]interface{}{ + "port": float64(5433), + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: "5433", + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "with scripts path", + input: map[string]interface{}{ + "scripts-path": "/path/to/scripts", + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + ScriptsPath: "/path/to/scripts", + }, + wantErr: false, + }, + { + name: "with container name", + input: map[string]interface{}{ + "container-name": "my-postgres", + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + ContainerName: "my-postgres", + }, + wantErr: false, + }, + { + name: "partial config", + input: map[string]interface{}{ + "user": "customuser", + "database": "customdb", + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: "customuser", + Password: defaultPassword, + Database: "customdb", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + got, err := plugin.parseConfig(tt.input) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.Port, got.Port) + assert.Equal(t, tt.want.User, got.User) + assert.Equal(t, tt.want.Password, got.Password) + assert.Equal(t, tt.want.Database, got.Database) + assert.Equal(t, tt.want.ScriptsPath, got.ScriptsPath) + if tt.want.ContainerName != "" { + assert.Equal(t, tt.want.ContainerName, got.ContainerName) + } else { + // Container name should be auto-generated + assert.NotEmpty(t, got.ContainerName) + assert.Contains(t, got.ContainerName, containerNamePrefix) + } + }) + } +} + +func TestGetConnectionInfo(t *testing.T) { + tests := []struct { + name string + config *PostgreSQLConfig + wantErr bool + errContains string + }{ + { + name: "valid config", + config: &PostgreSQLConfig{ + Port: "5432", + User: "testuser", + Password: "testpass", + Database: "testdb", + }, + wantErr: false, + }, + { + name: "no config", + config: nil, + wantErr: true, + errContains: "not launched", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + plugin.config = tt.config + + got, err := plugin.GetConnectionInfo() + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, mustParsePort(tt.config.Port), got.Port) + assert.Equal(t, "postgresql", got.Protocol) + assert.Equal(t, tt.config.User, got.Metadata["user"]) + assert.Equal(t, tt.config.Password, got.Metadata["password"]) + assert.Equal(t, tt.config.Database, got.Metadata["database"]) + assert.Equal(t, "disable", got.Metadata["sslmode"]) + }) + } +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + { + name: "standard port", + input: "5432", + want: 5432, + }, + { + name: "custom port", + input: "5433", + want: 5433, + }, + { + name: "zero returns zero", + input: "0", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mustParsePort(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsReady_NotStarted(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + + // Should return error when container not started + ready, err := plugin.IsReady(nil) + assert.False(t, ready) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, zap.NewNop()) + + // Should not error when no container to stop + err := plugin.Stop(nil) + assert.NoError(t, err) +} diff --git a/internal/plugin/services/pubsub/integration_test.go b/internal/plugin/services/pubsub/integration_test.go new file mode 100644 index 0000000..4010a80 --- /dev/null +++ b/internal/plugin/services/pubsub/integration_test.go @@ -0,0 +1,92 @@ +//go:build integration +// +build integration + +package pubsub + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/pkg/logger" +) + +func TestPubSubIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := logger.Default() + defer log.Sync() + + dockerClient, err := docker.NewClient(log.Logger) + require.NoError(t, err, "Failed to create Docker client") + defer dockerClient.Close() + + ctx := context.Background() + err = dockerClient.Ping(ctx) + require.NoError(t, err, "Docker daemon not available") + + plugin := NewPubSubPlugin(dockerClient, log.Logger) + require.NotNil(t, plugin) + + const port = "18085" + config := map[string]interface{}{ + "image": defaultImage, + "port": port, + "project-id": "test-project", + "topics": []interface{}{"orders"}, + "subscriptions": []interface{}{ + map[string]interface{}{"name": "orders-sub", "topic": "orders"}, + }, + } + + log.Info("launching Pub/Sub emulator for integration test") + err = plugin.Launch(ctx, config) + require.NoError(t, err, "Failed to launch Pub/Sub") + + defer func() { + log.Info("cleaning up Pub/Sub container") + if err := plugin.Stop(ctx); err != nil { + t.Logf("Failed to stop Pub/Sub: %v", err) + } + }() + + t.Run("IsReady", func(t *testing.T) { + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "Pub/Sub should be ready") + }) + + t.Run("GetConnectionInfo", func(t *testing.T) { + connInfo, err := plugin.GetConnectionInfo() + require.NoError(t, err) + assert.Equal(t, "localhost", connInfo.Host) + assert.Equal(t, 18085, connInfo.Port) + assert.Equal(t, "test-project", connInfo.Metadata["project-id"]) + }) + + t.Run("TopicCreated", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/v1/projects/test-project/topics/orders", port)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "topic 'orders' should exist") + }) + + t.Run("SubscriptionCreated", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/v1/projects/test-project/subscriptions/orders-sub", port)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "subscription 'orders-sub' should exist") + }) + + t.Run("Stop", func(t *testing.T) { + err := plugin.Stop(ctx) + require.NoError(t, err, "Failed to stop Pub/Sub") + }) +} diff --git a/internal/plugin/services/pubsub/pubsub.go b/internal/plugin/services/pubsub/pubsub.go new file mode 100644 index 0000000..eedaefb --- /dev/null +++ b/internal/plugin/services/pubsub/pubsub.go @@ -0,0 +1,454 @@ +package pubsub + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "gcr.io/google.com/cloudsdktool/cloud-sdk:emulators" + defaultPort = "8085" + defaultProjectID = "test-project" + containerNamePrefix = "gtool-pubsub" +) + +// PubSubPlugin implements the ServicePlugin interface for the Google Cloud +// Pub/Sub emulator. +type PubSubPlugin struct { + docker *docker.Client + logger *zap.Logger + httpClient *http.Client + containerID string + config *PubSubConfig +} + +// SubscriptionConfig describes a subscription bound to a topic +type SubscriptionConfig struct { + Name string `json:"name"` + Topic string `json:"topic"` +} + +// PubSubConfig holds Pub/Sub-specific configuration +type PubSubConfig struct { + Image string `json:"image"` + Port string `json:"port"` + ProjectID string `json:"project-id"` + Topics []string `json:"topics"` + Subscriptions []SubscriptionConfig `json:"subscriptions"` + ContainerName string `json:"container-name"` +} + +// NewPubSubPlugin creates a new Pub/Sub service plugin +func NewPubSubPlugin(dockerClient *docker.Client, logger *zap.Logger) *PubSubPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &PubSubPlugin{ + docker: dockerClient, + logger: logger, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Name returns the service identifier +func (p *PubSubPlugin) Name() string { + return "pubsub" +} + +// Launch starts the Pub/Sub emulator with given configuration +func (p *PubSubPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching Pub/Sub emulator") + + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse Pub/Sub configuration") + } + p.config = cfg + + p.logger.Info("pulling Pub/Sub image", zap.String("image", cfg.Image)) + if err := p.docker.EnsureImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull Pub/Sub image") + } + + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + Cmd: []string{ + "gcloud", "beta", "emulators", "pubsub", "start", + "--host-port=0.0.0.0:8085", + fmt.Sprintf("--project=%s", cfg.ProjectID), + }, + PortBindings: map[string]string{ + "8085": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "pubsub", + }, + } + + p.logger.Info("creating Pub/Sub container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create Pub/Sub container") + } + p.containerID = containerID + + p.logger.Info("starting Pub/Sub container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start Pub/Sub container") + } + + p.logger.Info("waiting for Pub/Sub emulator to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "Pub/Sub emulator did not become ready") + } + + // Create topics, then subscriptions (which reference topics) + if len(cfg.Topics) > 0 { + p.logger.Info("creating topics", zap.Strings("topics", cfg.Topics)) + if err := p.createTopics(ctx, cfg); err != nil { + p.logger.Error("failed to create topics", zap.Error(err)) + } + } + if len(cfg.Subscriptions) > 0 { + p.logger.Info("creating subscriptions", zap.Int("count", len(cfg.Subscriptions))) + if err := p.createSubscriptions(ctx, cfg); err != nil { + p.logger.Error("failed to create subscriptions", zap.Error(err)) + } + } + + p.logger.Info("Pub/Sub emulator launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the Pub/Sub emulator REST API is accepting requests +func (p *PubSubPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "Pub/Sub container not started") + } + + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Listing topics succeeds only once the emulator is serving requests + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.listTopicsURL(), nil) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build readiness request") + } + + resp, err := p.httpClient.Do(req) + if err != nil { + p.logger.Debug("Pub/Sub not ready yet", zap.Error(err)) + return false, nil + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK, nil +} + +// Stop terminates the Pub/Sub service +func (p *PubSubPlugin) Stop(ctx context.Context) error { + if p.containerID == "" { + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "pubsub", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Pub/Sub containers") + } + + if len(containers) == 0 { + p.logger.Warn("no Pub/Sub containers found to stop") + return nil + } + + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found Pub/Sub container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *PubSubPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping Pub/Sub service", zap.String("containerID", p.containerID)) + + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove Pub/Sub container") + } + + p.logger.Info("Pub/Sub service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *PubSubPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Pub/Sub service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "http", + Metadata: map[string]string{ + "emulator-host": fmt.Sprintf("localhost:%s", p.config.Port), + "project-id": p.config.ProjectID, + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *PubSubPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + if containerID == "" { + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "pubsub", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Pub/Sub containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Pub/Sub container not found") + } + + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found Pub/Sub container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into PubSubConfig +func (p *PubSubPlugin) parseConfig(config map[string]interface{}) (*PubSubConfig, error) { + cfg := &PubSubConfig{ + Image: defaultImage, + Port: defaultPort, + ProjectID: defaultProjectID, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if projectID, ok := config["project-id"].(string); ok && projectID != "" { + cfg.ProjectID = projectID + } + if topics, ok := config["topics"].([]interface{}); ok { + for _, t := range topics { + if name, ok := t.(string); ok && name != "" { + cfg.Topics = append(cfg.Topics, name) + } + } + } + if subs, ok := config["subscriptions"].([]interface{}); ok { + for _, s := range subs { + if m, ok := s.(map[string]interface{}); ok { + name, _ := m["name"].(string) + topic, _ := m["topic"].(string) + if name != "" && topic != "" { + cfg.Subscriptions = append(cfg.Subscriptions, SubscriptionConfig{Name: name, Topic: topic}) + } + } + } + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for the Pub/Sub emulator to start serving requests +func (p *PubSubPlugin) waitForReady(ctx context.Context) error { + maxRetries := 30 + interval := time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("Pub/Sub emulator is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for Pub/Sub") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("Pub/Sub emulator did not become ready after %d attempts", maxRetries)) +} + +// createTopics creates the configured topics via the emulator REST API +func (p *PubSubPlugin) createTopics(ctx context.Context, cfg *PubSubConfig) error { + for _, topic := range cfg.Topics { + if err := p.putResource(ctx, p.topicURL(topic), nil); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to create topic: %s", topic)) + } + p.logger.Info("topic created", zap.String("topic", topic)) + } + return nil +} + +// createSubscriptions creates the configured subscriptions, each bound to its topic +func (p *PubSubPlugin) createSubscriptions(ctx context.Context, cfg *PubSubConfig) error { + for _, sub := range cfg.Subscriptions { + body := []byte(fmt.Sprintf(`{"topic":"projects/%s/topics/%s"}`, cfg.ProjectID, sub.Topic)) + if err := p.putResource(ctx, p.subscriptionURL(sub.Name), body); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to create subscription: %s", sub.Name)) + } + p.logger.Info("subscription created", + zap.String("subscription", sub.Name), + zap.String("topic", sub.Topic)) + } + return nil +} + +// putResource issues a PUT to the emulator REST API to create a resource +func (p *PubSubPlugin) putResource(ctx context.Context, url string, body []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "failed to build request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "request failed") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict { + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("unexpected status %d for %s", resp.StatusCode, url)) + } + return nil +} + +// baseURL returns the emulator REST API base for the configured project +func (p *PubSubPlugin) baseURL() string { + return fmt.Sprintf("http://localhost:%s/v1/projects/%s", p.config.Port, p.config.ProjectID) +} + +func (p *PubSubPlugin) listTopicsURL() string { + return p.baseURL() + "/topics" +} + +func (p *PubSubPlugin) topicURL(topic string) string { + return fmt.Sprintf("%s/topics/%s", p.baseURL(), topic) +} + +func (p *PubSubPlugin) subscriptionURL(sub string) string { + return fmt.Sprintf("%s/subscriptions/%s", p.baseURL(), sub) +} + +// mustParsePort parses port string to int, returns 0 on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/pubsub/pubsub_test.go b/internal/plugin/services/pubsub/pubsub_test.go new file mode 100644 index 0000000..abe655f --- /dev/null +++ b/internal/plugin/services/pubsub/pubsub_test.go @@ -0,0 +1,122 @@ +package pubsub + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewPubSubPlugin(t *testing.T) { + p := NewPubSubPlugin(nil, zap.NewNop()) + + assert.NotNil(t, p) + assert.Equal(t, "pubsub", p.Name()) + assert.NotNil(t, p.httpClient) +} + +func TestName(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + assert.Equal(t, "pubsub", p.Name()) +} + +func TestParseConfig(t *testing.T) { + t.Run("default config", func(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{}) + + require.NoError(t, err) + assert.Equal(t, defaultImage, got.Image) + assert.Equal(t, defaultPort, got.Port) + assert.Equal(t, defaultProjectID, got.ProjectID) + assert.Empty(t, got.Topics) + assert.Empty(t, got.Subscriptions) + assert.Contains(t, got.ContainerName, containerNamePrefix) + }) + + t.Run("topics, subscriptions and numeric port", func(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{ + "port": float64(18085), + "project-id": "my-project", + "topics": []interface{}{"orders", "", 42, "payments"}, + "subscriptions": []interface{}{ + map[string]interface{}{"name": "orders-sub", "topic": "orders"}, + map[string]interface{}{"name": "", "topic": "orders"}, // skipped: no name + map[string]interface{}{"name": "bad-sub"}, // skipped: no topic + }, + }) + + require.NoError(t, err) + assert.Equal(t, "18085", got.Port) + assert.Equal(t, "my-project", got.ProjectID) + assert.Equal(t, []string{"orders", "payments"}, got.Topics) + require.Len(t, got.Subscriptions, 1) + assert.Equal(t, SubscriptionConfig{Name: "orders-sub", Topic: "orders"}, got.Subscriptions[0]) + }) + + t.Run("custom container name", func(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + got, err := p.parseConfig(map[string]interface{}{"container-name": "my-pubsub"}) + + require.NoError(t, err) + assert.Equal(t, "my-pubsub", got.ContainerName) + }) +} + +func TestGetConnectionInfo(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + p.config = &PubSubConfig{Port: "8085", ProjectID: "test-project"} + + got, err := p.GetConnectionInfo() + + require.NoError(t, err) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, 8085, got.Port) + assert.Equal(t, "http", got.Protocol) + assert.Equal(t, "localhost:8085", got.Metadata["emulator-host"]) + assert.Equal(t, "test-project", got.Metadata["project-id"]) + }) + + t.Run("not launched", func(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + + _, err := p.GetConnectionInfo() + + require.Error(t, err) + assert.Contains(t, err.Error(), "not launched") + }) +} + +func TestResourceURLs(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + p.config = &PubSubConfig{Port: "8085", ProjectID: "test-project"} + + assert.Equal(t, "http://localhost:8085/v1/projects/test-project/topics", p.listTopicsURL()) + assert.Equal(t, "http://localhost:8085/v1/projects/test-project/topics/orders", p.topicURL("orders")) + assert.Equal(t, "http://localhost:8085/v1/projects/test-project/subscriptions/orders-sub", p.subscriptionURL("orders-sub")) +} + +func TestIsReady_NotStarted(t *testing.T) { + p := NewPubSubPlugin(nil, nil) + + ready, err := p.IsReady(nil) + + assert.False(t, ready) + require.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + p := NewPubSubPlugin(nil, zap.NewNop()) + + err := p.Stop(nil) + assert.NoError(t, err) +} + +func TestMustParsePort(t *testing.T) { + assert.Equal(t, 8085, mustParsePort("8085")) + assert.Equal(t, 0, mustParsePort("0")) +} diff --git a/internal/plugin/services/redis/redis.go b/internal/plugin/services/redis/redis.go new file mode 100644 index 0000000..95e9a76 --- /dev/null +++ b/internal/plugin/services/redis/redis.go @@ -0,0 +1,362 @@ +package redis + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldom-code/gtool/internal/infra/docker" + "github.com/oswaldom-code/gtool/internal/plugin" + gtErrors "github.com/oswaldom-code/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "redis:7-alpine" + defaultPort = "6379" + containerNamePrefix = "gtool-redis" +) + +// RedisPlugin implements the ServicePlugin interface for Redis +type RedisPlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *RedisConfig +} + +// RedisConfig holds Redis-specific configuration +type RedisConfig struct { + Image string `json:"image"` + Port string `json:"port"` + Password string `json:"password"` + ContainerName string `json:"container-name"` +} + +// NewRedisPlugin creates a new Redis service plugin +func NewRedisPlugin(dockerClient *docker.Client, logger *zap.Logger) *RedisPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &RedisPlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *RedisPlugin) Name() string { + return "redis" +} + +// Launch starts the Redis service with given configuration +func (p *RedisPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching Redis service") + + // Parse configuration + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse Redis configuration") + } + p.config = cfg + + // Pull image + p.logger.Info("pulling Redis image", zap.String("image", cfg.Image)) + if err := p.docker.EnsureImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull Redis image") + } + + // Create container + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + PortBindings: map[string]string{ + "6379": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "redis", + }, + } + + if cfg.Password != "" { + containerConfig.Cmd = []string{"redis-server", "--requirepass", cfg.Password} + } + + p.logger.Info("creating Redis container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create Redis container") + } + p.containerID = containerID + + // Start container + p.logger.Info("starting Redis container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start Redis container") + } + + // Wait for Redis to be ready + p.logger.Info("waiting for Redis to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "Redis did not become ready") + } + + p.logger.Info("Redis service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the Redis service is ready to accept connections +func (p *RedisPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "Redis container not started") + } + + // Check if container is running + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Check if Redis is ready by executing redis-cli ping + cmd := []string{"redis-cli"} + if p.config.Password != "" { + cmd = append(cmd, "-a", p.config.Password) + } + cmd = append(cmd, "ping") + + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Debug("Redis not ready yet", zap.String("output", output)) + return false, nil + } + + return true, nil +} + +// Stop terminates the Redis service +func (p *RedisPlugin) Stop(ctx context.Context) error { + // If no containerID, try to find container by labels + if p.containerID == "" { + // If no Docker client, nothing to stop + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "redis", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Redis containers") + } + + if len(containers) == 0 { + p.logger.Warn("no Redis containers found to stop") + return nil + } + + // Stop all matching containers + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found Redis container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *RedisPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping Redis service", zap.String("containerID", p.containerID)) + + // Stop container + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + // Remove container + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove Redis container") + } + + p.logger.Info("Redis service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *RedisPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Redis service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "redis", + Metadata: map[string]string{ + "password": p.config.Password, + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *RedisPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + // If no containerID, try to find container by labels + if containerID == "" { + // If no Docker client, cannot get logs + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "redis", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list Redis containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "Redis container not found") + } + + // Find first running container + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + // Fallback to first container if none are running + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found Redis container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + // Split logs into lines + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into RedisConfig +func (p *RedisPlugin) parseConfig(config map[string]interface{}) (*RedisConfig, error) { + cfg := &RedisConfig{ + Image: defaultImage, + Port: defaultPort, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + // Override with provided values + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if password, ok := config["password"].(string); ok && password != "" { + cfg.Password = password + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for Redis to be ready +func (p *RedisPlugin) waitForReady(ctx context.Context) error { + maxRetries := 30 + interval := 1 * time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("Redis is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for Redis") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("Redis did not become ready after %d attempts", maxRetries)) +} + +// mustParsePort parses port string to int, panics on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/redis/redis_test.go b/internal/plugin/services/redis/redis_test.go new file mode 100644 index 0000000..a7c1a5b --- /dev/null +++ b/internal/plugin/services/redis/redis_test.go @@ -0,0 +1,224 @@ +package redis + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewRedisPlugin(t *testing.T) { + logger := zap.NewNop() + plugin := NewRedisPlugin(nil, logger) + + assert.NotNil(t, plugin) + assert.Equal(t, "redis", plugin.Name()) +} + +func TestName(t *testing.T) { + plugin := NewRedisPlugin(nil, nil) + assert.Equal(t, "redis", plugin.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want *RedisConfig + wantErr bool + errContains string + }{ + { + name: "default config", + input: map[string]interface{}{}, + want: &RedisConfig{ + Image: defaultImage, + Port: defaultPort, + }, + wantErr: false, + }, + { + name: "custom config with string port", + input: map[string]interface{}{ + "image": "redis:6", + "port": "6380", + }, + want: &RedisConfig{ + Image: "redis:6", + Port: "6380", + }, + wantErr: false, + }, + { + name: "custom config with numeric port", + input: map[string]interface{}{ + "port": float64(6380), + }, + want: &RedisConfig{ + Image: defaultImage, + Port: "6380", + }, + wantErr: false, + }, + { + name: "with password", + input: map[string]interface{}{ + "password": "secret", + }, + want: &RedisConfig{ + Image: defaultImage, + Port: defaultPort, + Password: "secret", + }, + wantErr: false, + }, + { + name: "with container name", + input: map[string]interface{}{ + "container-name": "my-redis", + }, + want: &RedisConfig{ + Image: defaultImage, + Port: defaultPort, + ContainerName: "my-redis", + }, + wantErr: false, + }, + { + name: "partial config", + input: map[string]interface{}{ + "image": "redis:7", + }, + want: &RedisConfig{ + Image: "redis:7", + Port: defaultPort, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewRedisPlugin(nil, nil) + got, err := plugin.parseConfig(tt.input) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.Port, got.Port) + assert.Equal(t, tt.want.Password, got.Password) + if tt.want.ContainerName != "" { + assert.Equal(t, tt.want.ContainerName, got.ContainerName) + } else { + // Container name should be auto-generated + assert.NotEmpty(t, got.ContainerName) + assert.Contains(t, got.ContainerName, containerNamePrefix) + } + }) + } +} + +func TestGetConnectionInfo(t *testing.T) { + tests := []struct { + name string + config *RedisConfig + wantErr bool + errContains string + }{ + { + name: "valid config", + config: &RedisConfig{ + Port: "6379", + Password: "testpass", + }, + wantErr: false, + }, + { + name: "no config", + config: nil, + wantErr: true, + errContains: "not launched", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewRedisPlugin(nil, nil) + plugin.config = tt.config + + got, err := plugin.GetConnectionInfo() + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, mustParsePort(tt.config.Port), got.Port) + assert.Equal(t, "redis", got.Protocol) + assert.Equal(t, tt.config.Password, got.Metadata["password"]) + }) + } +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + { + name: "standard port", + input: "6379", + want: 6379, + }, + { + name: "custom port", + input: "6380", + want: 6380, + }, + { + name: "zero returns zero", + input: "0", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mustParsePort(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsReady_NotStarted(t *testing.T) { + plugin := NewRedisPlugin(nil, nil) + + // Should return error when container not started + ready, err := plugin.IsReady(nil) + assert.False(t, ready) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + plugin := NewRedisPlugin(nil, zap.NewNop()) + + // Should not error when no container to stop + err := plugin.Stop(nil) + assert.NoError(t, err) +} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..730b613 --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,70 @@ +package config + +import "time" + +type Config struct { + Version string `yaml:"version" json:"version"` + AppTechnology string `yaml:"app-technology" json:"app-technology"` + AppConfig AppConfig `yaml:"app-config" json:"app-config"` + TestLauncher string `yaml:"test-launcher" json:"test-launcher"` + TestConfig TestConfig `yaml:"test-config" json:"test-config"` + ThirdParty ThirdPartyConfig `yaml:"third-party" json:"third-party"` + Orchestration OrchestrationConfig `yaml:"orchestration" json:"orchestration"` + Observability ObservabilityConfig `yaml:"observability" json:"observability"` +} + +type AppConfig struct { + BinaryName string `yaml:"binary-name" json:"binary-name"` + BinaryPath string `yaml:"binary-path" json:"binary-path"` + DockerImage string `yaml:"docker-image" json:"docker-image"` + Port int `yaml:"port" json:"port"` + Environment map[string]string `yaml:"environment" json:"environment"` +} + +type TestConfig struct { + Tags string `yaml:"tags" json:"tags"` + FeaturesPath string `yaml:"features-path" json:"features-path"` + ReportsPath string `yaml:"reports-path" json:"reports-path"` + Parallel bool `yaml:"parallel" json:"parallel"` +} + +type ThirdPartyConfig struct { + Mocks []string `yaml:"mocks" json:"mocks"` + MockConfig map[string]interface{} `yaml:"mock-config" json:"mock-config"` +} + +type OrchestrationConfig struct { + ParallelMocks bool `yaml:"parallel-mocks" json:"parallel-mocks"` + StartupTimeout time.Duration `yaml:"startup-timeout" json:"startup-timeout"` + HealthCheckInterval time.Duration `yaml:"health-check-interval" json:"health-check-interval"` + HealthCheckRetries int `yaml:"health-check-retries" json:"health-check-retries"` + CleanupOnFailure bool `yaml:"cleanup-on-failure" json:"cleanup-on-failure"` + PreserveLogs bool `yaml:"preserve-logs" json:"preserve-logs"` +} + +type ObservabilityConfig struct { + StructuredLogs bool `yaml:"structured-logs" json:"structured-logs"` + LogLevel string `yaml:"log-level" json:"log-level"` + MetricsEnabled bool `yaml:"metrics-enabled" json:"metrics-enabled"` + ReportFormat string `yaml:"report-format" json:"report-format"` +} + +func DefaultConfig() *Config { + return &Config{ + Version: "v1", + Orchestration: OrchestrationConfig{ + ParallelMocks: true, + StartupTimeout: 180 * time.Second, + HealthCheckInterval: 3 * time.Second, + HealthCheckRetries: 60, + CleanupOnFailure: true, + PreserveLogs: true, + }, + Observability: ObservabilityConfig{ + StructuredLogs: false, + LogLevel: "info", + MetricsEnabled: true, + ReportFormat: "text", + }, + } +} diff --git a/pkg/config/types_test.go b/pkg/config/types_test.go new file mode 100644 index 0000000..24bc2a7 --- /dev/null +++ b/pkg/config/types_test.go @@ -0,0 +1,193 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + assert.NotNil(t, cfg) + assert.Equal(t, "v1", cfg.Version) + assert.True(t, cfg.Orchestration.ParallelMocks) + assert.Equal(t, 180*time.Second, cfg.Orchestration.StartupTimeout) + assert.Equal(t, 3*time.Second, cfg.Orchestration.HealthCheckInterval) + assert.Equal(t, 60, cfg.Orchestration.HealthCheckRetries) + assert.True(t, cfg.Orchestration.CleanupOnFailure) + assert.True(t, cfg.Orchestration.PreserveLogs) + assert.False(t, cfg.Observability.StructuredLogs) + assert.Equal(t, "info", cfg.Observability.LogLevel) + assert.True(t, cfg.Observability.MetricsEnabled) + assert.Equal(t, "text", cfg.Observability.ReportFormat) +} + +func TestConfigStructure(t *testing.T) { + cfg := &Config{ + Version: "v1", + AppTechnology: "golang", + AppConfig: AppConfig{ + BinaryName: "myapp", + BinaryPath: "./bin", + Port: 8080, + Environment: map[string]string{ + "LOG_LEVEL": "debug", + }, + }, + TestLauncher: "test-launcher-back", + TestConfig: TestConfig{ + Tags: "@smoke", + FeaturesPath: "./features", + ReportsPath: "./reports", + Parallel: true, + }, + ThirdParty: ThirdPartyConfig{ + Mocks: []string{"couchbase", "pubsub"}, + MockConfig: map[string]interface{}{ + "couchbase": map[string]interface{}{ + "bucket": "test", + }, + }, + }, + Orchestration: OrchestrationConfig{ + ParallelMocks: true, + StartupTimeout: 180 * time.Second, + HealthCheckInterval: 3 * time.Second, + HealthCheckRetries: 60, + CleanupOnFailure: true, + PreserveLogs: true, + }, + Observability: ObservabilityConfig{ + StructuredLogs: true, + LogLevel: "debug", + MetricsEnabled: true, + ReportFormat: "json", + }, + } + + // Test basic fields + assert.Equal(t, "v1", cfg.Version) + assert.Equal(t, "golang", cfg.AppTechnology) + assert.Equal(t, "test-launcher-back", cfg.TestLauncher) + + // Test app config + assert.Equal(t, "myapp", cfg.AppConfig.BinaryName) + assert.Equal(t, "./bin", cfg.AppConfig.BinaryPath) + assert.Equal(t, 8080, cfg.AppConfig.Port) + assert.Equal(t, "debug", cfg.AppConfig.Environment["LOG_LEVEL"]) + + // Test test config + assert.Equal(t, "@smoke", cfg.TestConfig.Tags) + assert.Equal(t, "./features", cfg.TestConfig.FeaturesPath) + assert.Equal(t, "./reports", cfg.TestConfig.ReportsPath) + assert.True(t, cfg.TestConfig.Parallel) + + // Test third party + assert.Len(t, cfg.ThirdParty.Mocks, 2) + assert.Contains(t, cfg.ThirdParty.Mocks, "couchbase") + assert.Contains(t, cfg.ThirdParty.Mocks, "pubsub") + + // Test orchestration + assert.True(t, cfg.Orchestration.ParallelMocks) + assert.Equal(t, 180*time.Second, cfg.Orchestration.StartupTimeout) + assert.Equal(t, 3*time.Second, cfg.Orchestration.HealthCheckInterval) + assert.Equal(t, 60, cfg.Orchestration.HealthCheckRetries) + + // Test observability + assert.True(t, cfg.Observability.StructuredLogs) + assert.Equal(t, "debug", cfg.Observability.LogLevel) + assert.True(t, cfg.Observability.MetricsEnabled) + assert.Equal(t, "json", cfg.Observability.ReportFormat) +} + +func TestAppConfig(t *testing.T) { + appCfg := AppConfig{ + BinaryName: "test-app", + BinaryPath: "/usr/local/bin", + DockerImage: "myapp:latest", + Port: 3000, + Environment: map[string]string{ + "ENV": "test", + "LOG_LEVEL": "info", + }, + } + + assert.Equal(t, "test-app", appCfg.BinaryName) + assert.Equal(t, "/usr/local/bin", appCfg.BinaryPath) + assert.Equal(t, "myapp:latest", appCfg.DockerImage) + assert.Equal(t, 3000, appCfg.Port) + assert.Len(t, appCfg.Environment, 2) +} + +func TestTestConfig(t *testing.T) { + testCfg := TestConfig{ + Tags: "@integration", + FeaturesPath: "/app/features", + ReportsPath: "/app/reports", + Parallel: false, + } + + assert.Equal(t, "@integration", testCfg.Tags) + assert.Equal(t, "/app/features", testCfg.FeaturesPath) + assert.Equal(t, "/app/reports", testCfg.ReportsPath) + assert.False(t, testCfg.Parallel) +} + +func TestThirdPartyConfig(t *testing.T) { + thirdParty := ThirdPartyConfig{ + Mocks: []string{"kafka", "postgresql"}, + MockConfig: map[string]interface{}{ + "kafka": map[string]interface{}{ + "topics": []string{"events", "logs"}, + }, + "postgresql": map[string]interface{}{ + "database": "testdb", + }, + }, + } + + assert.Len(t, thirdParty.Mocks, 2) + assert.Len(t, thirdParty.MockConfig, 2) + assert.Contains(t, thirdParty.MockConfig, "kafka") + assert.Contains(t, thirdParty.MockConfig, "postgresql") +} + +func TestOrchestrationConfig(t *testing.T) { + orch := OrchestrationConfig{ + ParallelMocks: false, + StartupTimeout: 300 * time.Second, + HealthCheckInterval: 5 * time.Second, + HealthCheckRetries: 30, + CleanupOnFailure: false, + PreserveLogs: false, + } + + assert.False(t, orch.ParallelMocks) + assert.Equal(t, 300*time.Second, orch.StartupTimeout) + assert.Equal(t, 5*time.Second, orch.HealthCheckInterval) + assert.Equal(t, 30, orch.HealthCheckRetries) + assert.False(t, orch.CleanupOnFailure) + assert.False(t, orch.PreserveLogs) +} + +func TestObservabilityConfig(t *testing.T) { + obs := ObservabilityConfig{ + StructuredLogs: true, + LogLevel: "warn", + MetricsEnabled: false, + ReportFormat: "html", + } + + assert.True(t, obs.StructuredLogs) + assert.Equal(t, "warn", obs.LogLevel) + assert.False(t, obs.MetricsEnabled) + assert.Equal(t, "html", obs.ReportFormat) +} + +func BenchmarkDefaultConfig(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = DefaultConfig() + } +} diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..70735e0 --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,75 @@ +package errors + +import ( + "fmt" +) + +// Error codes +const ( + ErrConfigInvalid = "ERR_CONFIG_INVALID" + ErrConfigNotFound = "ERR_CONFIG_NOT_FOUND" + ErrServiceTimeout = "ERR_SERVICE_TIMEOUT" + ErrServiceFailed = "ERR_SERVICE_FAILED" + ErrServiceNotReady = "ERR_SERVICE_NOT_READY" + ErrServiceNotRunning = "ERR_SERVICE_NOT_RUNNING" + ErrTestFailed = "ERR_TEST_FAILED" + ErrDockerConnection = "ERR_DOCKER_CONNECTION" + ErrDockerFailed = "ERR_DOCKER_FAILED" + ErrContainerFailed = "ERR_CONTAINER_FAILED" + ErrProcessFailed = "ERR_PROCESS_FAILED" + ErrInvalidArgument = "ERR_INVALID_ARGUMENT" +) + +// GTError represents a gtool error with code and context +type GTError struct { + Code string + Message string + Cause error + Context map[string]interface{} +} + +// Error implements the error interface +func (e *GTError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +// Unwrap returns the underlying error +func (e *GTError) Unwrap() error { + return e.Cause +} + +// New creates a new GTError +func New(code, message string) *GTError { + return >Error{ + Code: code, + Message: message, + Context: make(map[string]interface{}), + } +} + +// Wrap wraps an error with a GTError +func Wrap(err error, code, message string) *GTError { + return >Error{ + Code: code, + Message: message, + Cause: err, + Context: make(map[string]interface{}), + } +} + +// WithContext adds context to the error +func (e *GTError) WithContext(key string, value interface{}) *GTError { + e.Context[key] = value + return e +} + +// Is checks if an error matches a code +func Is(err error, code string) bool { + if gtErr, ok := err.(*GTError); ok { + return gtErr.Code == code + } + return false +} diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go new file mode 100644 index 0000000..790c7c4 --- /dev/null +++ b/pkg/errors/errors_test.go @@ -0,0 +1,256 @@ +package errors + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + code string + message string + }{ + { + name: "simple error", + code: ErrConfigInvalid, + message: "invalid configuration", + }, + { + name: "service error", + code: ErrServiceFailed, + message: "service failed to start", + }, + { + name: "test error", + code: ErrTestFailed, + message: "test execution failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := New(tt.code, tt.message) + + assert.NotNil(t, err) + assert.Equal(t, tt.code, err.Code) + assert.Equal(t, tt.message, err.Message) + assert.Nil(t, err.Cause) + assert.NotNil(t, err.Context) + assert.Empty(t, err.Context) + }) + } +} + +func TestWrap(t *testing.T) { + originalErr := errors.New("original error") + + tests := []struct { + name string + err error + code string + message string + }{ + { + name: "wrap with config error", + err: originalErr, + code: ErrConfigInvalid, + message: "failed to load config", + }, + { + name: "wrap with service error", + err: originalErr, + code: ErrServiceFailed, + message: "service startup failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wrapped := Wrap(tt.err, tt.code, tt.message) + + assert.NotNil(t, wrapped) + assert.Equal(t, tt.code, wrapped.Code) + assert.Equal(t, tt.message, wrapped.Message) + assert.Equal(t, tt.err, wrapped.Cause) + }) + } +} + +func TestGTError_Error(t *testing.T) { + tests := []struct { + name string + err *GTError + expected string + }{ + { + name: "error without cause", + err: >Error{ + Code: ErrConfigInvalid, + Message: "invalid config", + }, + expected: "[ERR_CONFIG_INVALID] invalid config", + }, + { + name: "error with cause", + err: >Error{ + Code: ErrConfigInvalid, + Message: "invalid config", + Cause: errors.New("file not found"), + }, + expected: "[ERR_CONFIG_INVALID] invalid config: file not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.err.Error() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGTError_Unwrap(t *testing.T) { + originalErr := errors.New("original") + wrapped := Wrap(originalErr, ErrServiceFailed, "service failed") + + unwrapped := wrapped.Unwrap() + assert.Equal(t, originalErr, unwrapped) +} + +func TestGTError_WithContext(t *testing.T) { + err := New(ErrServiceFailed, "service failed") + + result := err.WithContext("service", "couchbase") + assert.Equal(t, err, result) // Should return same instance + assert.Equal(t, "couchbase", err.Context["service"]) + + // Add multiple context values + err.WithContext("port", 8091) + err.WithContext("timeout", "30s") + + assert.Equal(t, 3, len(err.Context)) + assert.Equal(t, 8091, err.Context["port"]) + assert.Equal(t, "30s", err.Context["timeout"]) +} + +func TestIs(t *testing.T) { + tests := []struct { + name string + err error + code string + expected bool + }{ + { + name: "matching code", + err: New(ErrConfigInvalid, "invalid"), + code: ErrConfigInvalid, + expected: true, + }, + { + name: "non-matching code", + err: New(ErrConfigInvalid, "invalid"), + code: ErrServiceFailed, + expected: false, + }, + { + name: "wrapped error matching", + err: Wrap(errors.New("cause"), ErrTestFailed, "test failed"), + code: ErrTestFailed, + expected: true, + }, + { + name: "non-GTError", + err: errors.New("standard error"), + code: ErrConfigInvalid, + expected: false, + }, + { + name: "nil error", + err: nil, + code: ErrConfigInvalid, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Is(tt.err, tt.code) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestErrorCodes(t *testing.T) { + // Test that all error codes are unique + codes := []string{ + ErrConfigInvalid, + ErrConfigNotFound, + ErrServiceTimeout, + ErrServiceFailed, + ErrServiceNotReady, + ErrTestFailed, + ErrDockerConnection, + ErrContainerFailed, + ErrProcessFailed, + ErrInvalidArgument, + } + + seen := make(map[string]bool) + for _, code := range codes { + assert.False(t, seen[code], "duplicate error code: %s", code) + seen[code] = true + } + + assert.Equal(t, len(codes), len(seen)) +} + +func ExampleNew() { + err := New(ErrConfigInvalid, "configuration is missing required field") + fmt.Println(err.Error()) + // Output: [ERR_CONFIG_INVALID] configuration is missing required field +} + +func ExampleWrap() { + originalErr := errors.New("file not found") + err := Wrap(originalErr, ErrConfigNotFound, "failed to load configuration") + fmt.Println(err.Error()) + // Output: [ERR_CONFIG_NOT_FOUND] failed to load configuration: file not found +} + +func ExampleGTError_WithContext() { + err := New(ErrServiceFailed, "service failed to start") + err.WithContext("service", "couchbase") + err.WithContext("port", 8091) + + fmt.Printf("Code: %s, Message: %s, Service: %s\n", + err.Code, err.Message, err.Context["service"]) + // Output: Code: ERR_SERVICE_FAILED, Message: service failed to start, Service: couchbase +} + +func BenchmarkNew(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = New(ErrConfigInvalid, "test error") + } +} + +func BenchmarkWrap(b *testing.B) { + originalErr := errors.New("original") + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = Wrap(originalErr, ErrServiceFailed, "wrapped error") + } +} + +func BenchmarkWithContext(b *testing.B) { + err := New(ErrServiceFailed, "service failed") + b.ResetTimer() + + for i := 0; i < b.N; i++ { + err.WithContext("iteration", i) + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..c6faa3d --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,46 @@ +package logger + +import ( + "context" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type Logger struct { + *zap.Logger +} + +func New(level string, structured bool) (*Logger, error) { + var config zap.Config + + if structured { + config = zap.NewProductionConfig() + } else { + config = zap.NewDevelopmentConfig() + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + } + + lvl, err := zapcore.ParseLevel(level) + if err != nil { + return nil, err + } + config.Level = zap.NewAtomicLevelAt(lvl) + + zapLogger, err := config.Build() + if err != nil { + return nil, err + } + + return &Logger{Logger: zapLogger}, nil +} + +func (l *Logger) WithContext(ctx context.Context, fields ...zap.Field) *zap.Logger { + // TODO: Extract fields from context (trace ID, request ID, etc.) + return l.Logger.With(fields...) +} + +func Default() *Logger { + logger, _ := New("info", false) + return logger +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go new file mode 100644 index 0000000..4d79d06 --- /dev/null +++ b/pkg/logger/logger_test.go @@ -0,0 +1,146 @@ +package logger + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + level string + structured bool + wantErr bool + }{ + { + name: "valid info level structured", + level: "info", + structured: true, + wantErr: false, + }, + { + name: "valid debug level", + level: "debug", + structured: false, + wantErr: false, + }, + { + name: "valid warn level", + level: "warn", + structured: true, + wantErr: false, + }, + { + name: "valid error level", + level: "error", + structured: false, + wantErr: false, + }, + { + name: "invalid level", + level: "invalid", + structured: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger, err := New(tt.level, tt.structured) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, logger) + } else { + assert.NoError(t, err) + assert.NotNil(t, logger) + assert.NotNil(t, logger.Logger) + } + }) + } +} + +func TestDefault(t *testing.T) { + logger := Default() + assert.NotNil(t, logger) + assert.NotNil(t, logger.Logger) +} + +func TestWithContext(t *testing.T) { + logger, err := New("info", true) + require.NoError(t, err) + require.NotNil(t, logger) + + ctx := context.Background() + field1 := zap.String("key1", "value1") + field2 := zap.Int("key2", 42) + + contextLogger := logger.WithContext(ctx, field1, field2) + assert.NotNil(t, contextLogger) +} + +func TestLoggerLevels(t *testing.T) { + levels := []string{"debug", "info", "warn", "error"} + + for _, level := range levels { + t.Run(level, func(t *testing.T) { + logger, err := New(level, true) + require.NoError(t, err) + require.NotNil(t, logger) + + assert.NotPanics(t, func() { + logger.Debug("debug message") + logger.Info("info message") + logger.Warn("warn message") + logger.Error("error message") + }) + }) + } +} + +func TestLoggerStructuredVsPlain(t *testing.T) { + t.Run("structured logger", func(t *testing.T) { + logger, err := New("info", true) + require.NoError(t, err) + require.NotNil(t, logger) + + assert.NotPanics(t, func() { + logger.Info("test message", zap.String("key", "value")) + }) + }) + + t.Run("plain logger", func(t *testing.T) { + logger, err := New("info", false) + require.NoError(t, err) + require.NotNil(t, logger) + + assert.NotPanics(t, func() { + logger.Info("test message", zap.String("key", "value")) + }) + }) +} + +func BenchmarkNew(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = New("info", true) + } +} + +func BenchmarkDefault(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = Default() + } +} + +func BenchmarkLogging(b *testing.B) { + logger, _ := New("info", true) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + logger.Info("benchmark message", zap.Int("iteration", i)) + } +} diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh new file mode 100755 index 0000000..924f27c --- /dev/null +++ b/scripts/setup-dev.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -e + +# Development Environment Setup Script + +echo "Setting up GTOOL development environment..." + +# Check Go version +echo "Checking Go version..." +if ! command -v go &> /dev/null; then + echo "✗ Go is not installed. Please install Go 1.24+" + exit 1 +fi + +GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//') +echo "✓ Go $GO_VERSION installed" + +# Check Docker +echo "Checking Docker..." +if ! command -v docker &> /dev/null; then + echo "✗ Docker is not installed. Please install Docker" + exit 1 +fi +echo "✓ Docker installed" + +# Check yq +echo "Checking yq..." +if ! command -v yq &> /dev/null; then + echo "⚠ yq is not installed. Installing..." + # Install yq based on OS + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + if [ "$OS" = "darwin" ]; then + brew install yq + elif [ "$OS" = "linux" ]; then + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + fi +fi +echo "✓ yq installed" + +# Download Go dependencies +echo "Downloading Go dependencies..." +go mod download +echo "✓ Dependencies downloaded" + +# Install development tools +echo "Installing development tools..." +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest +go install golang.org/x/tools/cmd/goimports@latest +echo "✓ Development tools installed" + +# Build the project +echo "Building gtool..." +make build +echo "✓ Build successful" + +# Run tests +echo "Running tests..." +make test +echo "✓ Tests passed" + +echo "" +echo "✓ Development environment setup complete!" +echo "" +echo "Next steps:" +echo " make dev - Run in development mode" +echo " make test - Run tests" +echo " make lint - Run linters" +echo " make help - Show all available commands" diff --git a/test-launcher/.dockerignore b/test-launcher/.dockerignore new file mode 100644 index 0000000..8432dae --- /dev/null +++ b/test-launcher/.dockerignore @@ -0,0 +1,3 @@ +target/ +.git/ +*.md diff --git a/test-launcher/.gitignore b/test-launcher/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/test-launcher/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/test-launcher/Dockerfile b/test-launcher/Dockerfile new file mode 100644 index 0000000..01fd5d3 --- /dev/null +++ b/test-launcher/Dockerfile @@ -0,0 +1,19 @@ +# Test launcher image (Karate + Java helpers) for gtool component testing. +# +# Build trick: `mvn test` runs with ZERO features because TestLauncher only runs +# `classpath:launcher/features`, a symlink to /app/features which is empty at +# build time (the real features are bind-mounted by gtool at runtime). This +# pre-caches all Maven dependencies into the image and compiles the helpers +# without executing any feature. +# +# The container MUST run as root: gtool's Karate runner writes reports as root +# and then chowns them back via a short-lived container using this same image. +FROM maven:3.9.9-eclipse-temurin-21 + +WORKDIR /app +COPY . . +RUN chmod +x scripts/run.bash && mvn -B test + +VOLUME /app/features + +CMD ["bash", "-c", "./scripts/run.bash"] diff --git a/test-launcher/README.md b/test-launcher/README.md new file mode 100644 index 0000000..4e0eab4 --- /dev/null +++ b/test-launcher/README.md @@ -0,0 +1,103 @@ +# gtool test-launcher + +Karate-based backend test launcher image used by `gtool` to run component tests +against an already-running app and its mocks. This is the open, in-repo +replacement for the legacy private `test-launcher-back` image — a "swiss-army" +launcher with helpers for the datastores, messaging systems, object storage and +test utilities a component test typically needs. + +## What it is + +- Base: `maven:3.9.9-eclipse-temurin-21` (public). +- Engine: [Karate](https://github.com/karatelabs/karate) `karate-junit5` (`io.karatelabs`, **1.5.2**). 1.5.x keeps the `com.intuit.karate.*` package namespace. +- Built as `gtool/test-launcher-back:latest` (the default image used by `gtool`). + +## Helpers + +### Globals (wired in `karate-config.js`, ready to use in every feature) + +| Variable | Class | Purpose | +|---|---|---| +| `ps` | `launcher.postgres.PostgresClient` | read / seed / assert against PostgreSQL (`localhost:5432`) | +| `du` | `utils.DateUtils` | timezone-aware date comparisons | +| `pdfu` | `utils.PdfUtils` | page-by-page PDF diff (writes a highlighted diff image) | +| `sleep(seconds)` | — | thread sleep | + +### Available via `Java.type(...)` + +Instantiate inside a feature, e.g.: + +```gherkin +* def Redis = Java.type('launcher.redis.RedisClient') +* def redis = new Redis({ host: 'localhost', port: '6379' }) +* match redis.get('some-key') == 'expected' +``` + +Each helper targets `localhost` (the container runs with `--network host`), so +ports line up with the mocks `gtool services up` starts. + +| Class | Domain | Default endpoint | +|---|---|---| +| `launcher.postgres.PostgresClient` | PostgreSQL (rows/fields/exists/update/delete/scripts/param queries) | `localhost:5432` | +| `launcher.mysql.MySqlClient` | MySQL/MariaDB (same API shape as PostgresClient) | from `url` | +| `launcher.couchbase.CouchbaseClient` | Couchbase (get field/document, exists, N1QL) | from `connectionString` | +| `launcher.redis.RedisClient` | Redis (set/get/exists/del/setEx) | `localhost:6379` | +| `launcher.mongo.MongoClient` | MongoDB (insert/find/findOne/exists/delete, JSON filters) | from `connectionString` | +| `launcher.kafka.KafkaClient` | Kafka producer (`publishMessage`) | `localhost:9092` | +| `launcher.pubsub.PubSub` + `launcher.pubsub.operations.*` | Pub/Sub emulator (publish, consume, ordered, find, create topic/subscription) | `PUBSUB_EMULATOR_HOST` | +| `launcher.gcs.operations.ReadObject` / `DeleteObject` | GCS emulator (fake-gcs-server) | `localhost:9086` | +| `launcher.amazon.sqs.operations.PublishSQSMessage` | SQS (publish) | `localhost:4566` | +| `launcher.s3.S3Client` | S3 / MinIO (put/get/exists/delete) | from `endpoint` | +| `launcher.util.Jwt` | generate / verify / inspect HS256 tokens | — | +| `launcher.util.Faker` | random test data (name/email/uuid/number/expression) | — | +| `launcher.util.JsonSchema` | JSON Schema (Draft 2020-12) validation | — | + +> Not included: a generic gRPC helper (needs per-service stubs) and a custom +> "await" helper (Karate already provides retry / `karate.repeat`). + +## Runtime contract (consumed by gtool) + +`gtool`'s Karate runner (`internal/core/test/stablekarate/runner.go`) expects: + +| Aspect | Value | +|---|---| +| Workdir | `/app` | +| Features (bind, ro) | `/app/features` | +| Reports (bind) | `/app/target/karate-reports` (HTML: `karate-summary.html`) | +| Entry | `CMD ["bash","-c","./scripts/run.bash"]` | +| Env | `TAGS`, `URLS_TO_BLOCK`, `PUBSUB_EMULATOR_HOST` | +| Network | host | +| User | root (reports are chowned back by gtool) | +| Pass/fail | container exit code | + +`src/test/java/launcher/features` is a symlink to `/app/features`, so user +features are picked up on the `classpath:launcher/features` path that +`TestLauncher` runs in parallel. + +### Build trick + +`RUN mvn test` during the image build executes **zero** features (the +`/app/features` volume is empty at build time), so it only pre-caches Maven +dependencies and compiles the helpers. + +## Build & run + +```bash +# build the image (or: make test-launcher-image from the repo root) +docker build -t gtool/test-launcher-back:latest test-launcher/ + +# via gtool, against a running app + mocks +cd examples/postgres-app +gtool services up +gtool test karate # uses gtool/test-launcher-back:latest by default +``` + +## Local helper unit tests + +```bash +cd test-launcher && mvn test # DateUtils, Jwt, Faker, JsonSchema, TestLauncher +``` + +> Datastore/messaging helpers are thin driver wrappers validated by compilation; +> end-to-end coverage requires the matching services (e.g. via `gtool services up`) +> or Testcontainers. diff --git a/test-launcher/features/.gitkeep b/test-launcher/features/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test-launcher/pom.xml b/test-launcher/pom.xml new file mode 100644 index 0000000..8c86624 --- /dev/null +++ b/test-launcher/pom.xml @@ -0,0 +1,201 @@ + + 4.0.0 + + com.gtool + test-launcher + 1.0.0 + jar + + + UTF-8 + 21 + 3.13.0 + 3.5.2 + 1.5.2 + logback-test.xml + + + + + + com.google.cloud + libraries-bom + 26.50.0 + pom + import + + + software.amazon.awssdk + bom + 2.30.15 + pom + import + + + + + + + + org.slf4j + slf4j-api + 2.0.16 + + + ch.qos.logback + logback-classic + 1.5.12 + + + + + io.karatelabs + karate-junit5 + ${karate.version} + test + + + + + com.jayway.jsonpath + json-path + 2.9.0 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.2 + + + + + org.postgresql + postgresql + 42.7.4 + + + com.mysql + mysql-connector-j + 9.1.0 + + + com.couchbase.client + java-client + 3.7.6 + + + redis.clients + jedis + 5.2.0 + + + org.mongodb + mongodb-driver-sync + 5.2.1 + + + + + org.apache.kafka + kafka-clients + 3.9.0 + + + com.google.cloud + google-cloud-pubsub + + + io.cloudevents + cloudevents-core + 4.0.1 + + + software.amazon.awssdk + sqs + + + + + com.google.cloud + google-cloud-storage + + + software.amazon.awssdk + s3 + + + + + com.networknt + json-schema-validator + 1.5.4 + + + com.auth0 + java-jwt + 4.4.0 + + + net.datafaker + datafaker + 2.4.2 + + + org.awaitility + awaitility + 4.2.2 + test + + + + + org.apache.pdfbox + pdfbox + 2.0.32 + + + + + + + src/test/java + + **/*.java + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.version} + + ${java.version} + UTF-8 + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.version} + + -Dfile.encoding=UTF-8 + + ${logback.configurationFile} + + + + + + + + + debug + + logback-test_debug.xml + + + + + diff --git a/test-launcher/scripts/run.bash b/test-launcher/scripts/run.bash new file mode 100755 index 0000000..8ad15c4 --- /dev/null +++ b/test-launcher/scripts/run.bash @@ -0,0 +1,20 @@ +#!/bin/bash + +# Disable Karate telemetry +export KARATE_TELEMETRY=false + +COMMAND="mvn -B test" + +# Filter scenarios by tag, e.g. TAGS="@smoke" +if [ "$TAGS" != "" ]; then + COMMAND+=" -Dkarate.options=\"--tags $TAGS\"" +fi + +# Verbose logging profile +if [ "$DEBUG" == true ]; then + COMMAND+=" -Pdebug" +fi + +eval $COMMAND + +exit $? diff --git a/test-launcher/settings.xml b/test-launcher/settings.xml new file mode 100644 index 0000000..eec93a2 --- /dev/null +++ b/test-launcher/settings.xml @@ -0,0 +1 @@ + diff --git a/test-launcher/src/main/java/utils/DateUtils.java b/test-launcher/src/main/java/utils/DateUtils.java new file mode 100644 index 0000000..5ec8c0f --- /dev/null +++ b/test-launcher/src/main/java/utils/DateUtils.java @@ -0,0 +1,82 @@ +package utils; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class to manipulate dates, exposed to Karate as {@code du}. + */ +public class DateUtils { + + private final Logger logger = LoggerFactory.getLogger(DateUtils.class); + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + /** + * Compares two ISO_OFFSET dates and checks whether they point to the same instant. + */ + public Boolean compareDates(final String date1, final String date2) { + final OffsetDateTime odt1 = OffsetDateTime.parse(date1, DATE_TIME_FORMATTER); + final OffsetDateTime odt2 = OffsetDateTime.parse(date2, DATE_TIME_FORMATTER); + final boolean ret = odt1.isEqual(odt2); + + logger.debug("Comparing dates: {} with {}. Result: {}", date1, date2, ret); + return ret; + } + + /** + * Compares two ISO_OFFSET dates with a tolerance threshold expressed in seconds. + */ + public Boolean compareDates(final String date1, final String date2, final long threshold) { + final OffsetDateTime odt1 = OffsetDateTime.parse(date1, DATE_TIME_FORMATTER); + final OffsetDateTime odt2 = OffsetDateTime.parse(date2, DATE_TIME_FORMATTER); + final long diff = odt2.toEpochSecond() - odt1.toEpochSecond(); + + return Math.abs(diff) <= threshold; + } + + /** + * Checks whether date1 isAfter date2, taking the timezone into account. + */ + public Boolean isAfter(final String date1, final String date2) { + final OffsetDateTime odt1 = OffsetDateTime.parse(date1, DATE_TIME_FORMATTER); + final OffsetDateTime odt2 = OffsetDateTime.parse(date2, DATE_TIME_FORMATTER); + + return odt1.isAfter(odt2); + } + + /** + * Checks whether date1 isBefore date2, taking the timezone into account. + */ + public Boolean isBefore(final String date1, final String date2) { + final OffsetDateTime odt1 = OffsetDateTime.parse(date1, DATE_TIME_FORMATTER); + final OffsetDateTime odt2 = OffsetDateTime.parse(date2, DATE_TIME_FORMATTER); + + return odt1.isBefore(odt2); + } + + /** + * Returns the given date converted to the system default timezone. + * Prefer {@link #dateInTimeZone(String, String)} with an explicit zone (e.g. "Europe/Madrid"). + */ + public String dateInCurrentTZ(final String date) { + return dateInTimeZone(date, ZoneId.systemDefault().normalized().toString()); + } + + /** + * Returns the given date converted to the given normalized timezone (e.g. "Europe/Madrid"). + */ + public String dateInTimeZone(final String date, final String timeZone) { + final ZoneId zId = ZoneId.of(timeZone); + final ZonedDateTime odt = OffsetDateTime.parse(date).atZoneSameInstant(zId); + final String ret = odt.format(DATE_TIME_FORMATTER); + logger.debug("date {} in timeZone {}: {}", date, timeZone, ret); + + return ret; + } +} diff --git a/test-launcher/src/main/java/utils/ImageUtil.java b/test-launcher/src/main/java/utils/ImageUtil.java new file mode 100644 index 0000000..ee84978 --- /dev/null +++ b/test-launcher/src/main/java/utils/ImageUtil.java @@ -0,0 +1,50 @@ +package utils; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; + +import javax.imageio.ImageIO; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class ImageUtil { + + static Logger logger = LoggerFactory.getLogger(ImageUtil.class); + + static boolean compareAndHighlight(final BufferedImage img1, final BufferedImage img2, + final String fileName, final boolean highlight, final int colorCode) throws IOException { + + final int w = img1.getWidth(); + final int h = img1.getHeight(); + final int[] p1 = img1.getRGB(0, 0, w, h, null, 0, w); + final int[] p2 = img2.getRGB(0, 0, w, h, null, 0, w); + + if (!Arrays.equals(p1, p2)) { + logger.warn("Image compared - does not match"); + if (highlight) { + for (int i = 0; i < p1.length; i++) { + if (p1[i] != p2[i]) { + p1[i] = colorCode; + } + } + final BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + out.setRGB(0, 0, w, h, p1, 0, w); + saveImage(out, fileName); + } + return false; + } + return true; + } + + static void saveImage(final BufferedImage image, final String file) { + try { + final File outputFile = new File(file); + ImageIO.write(image, "png", outputFile); + } catch (Exception e) { + logger.error("Could not save image {}", file, e); + } + } +} diff --git a/test-launcher/src/main/java/utils/PdfUtils.java b/test-launcher/src/main/java/utils/PdfUtils.java new file mode 100644 index 0000000..081c4b6 --- /dev/null +++ b/test-launcher/src/main/java/utils/PdfUtils.java @@ -0,0 +1,47 @@ +package utils; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class to compare PDF files page by page, exposed to Karate as {@code pdfu}. + * On mismatch, a highlighted diff image is written to the Karate reports directory. + */ +public class PdfUtils { + + private final Logger logger = LoggerFactory.getLogger(PdfUtils.class); + + private static final String DIFF_IMAGE = "/app/target/karate-reports/invoice_diff.png"; + + public Boolean comparePdfs(final byte[] file1, final byte[] file2) throws IOException { + try (PDDocument doc1 = PDDocument.load(file1); + PDDocument doc2 = PDDocument.load(file2)) { + + if (doc1.getNumberOfPages() != doc2.getNumberOfPages()) { + logger.warn("files page counts do not match - returning false"); + return false; + } + + final PDFRenderer renderer1 = new PDFRenderer(doc1); + final PDFRenderer renderer2 = new PDFRenderer(doc2); + + for (int page = 0; page < doc1.getNumberOfPages(); page++) { + final BufferedImage image1 = renderer1.renderImageWithDPI(page, 300, ImageType.RGB); + final BufferedImage image2 = renderer2.renderImageWithDPI(page, 300, ImageType.RGB); + final boolean equal = ImageUtil.compareAndHighlight(image1, image2, DIFF_IMAGE, true, + Color.MAGENTA.getRGB()); + if (!equal) { + return false; + } + } + return true; + } + } +} diff --git a/test-launcher/src/test/java/karate-config.js b/test-launcher/src/test/java/karate-config.js new file mode 100644 index 0000000..98051d3 --- /dev/null +++ b/test-launcher/src/test/java/karate-config.js @@ -0,0 +1,31 @@ +function fn() { + + var env = karate.env; // get system property 'karate.env' + karate.log('karate.env system property was:', env); + + var config = {}; + + // Sleep helper: sleep(seconds) + var sleep = function (seconds) { + java.lang.Thread.sleep(seconds * 1000); + }; + config.sleep = sleep; + + // PostgreSQL helper (assertions / seeding against a running Postgres on localhost) + var PostgresClient = Java.type('launcher.postgres.PostgresClient'); + config.ps = new PostgresClient({ + url: 'jdbc:postgresql://localhost:5432/postgres', + user: 'postgres', + password: 'postgres' + }); + + // Date helper + var DateUtils = Java.type('utils.DateUtils'); + config.du = new DateUtils(); + + // PDF comparison helper + var PdfUtils = Java.type('utils.PdfUtils'); + config.pdfu = new PdfUtils(); + + return config; +} diff --git a/test-launcher/src/test/java/launcher/TestLauncher.java b/test-launcher/src/test/java/launcher/TestLauncher.java new file mode 100644 index 0000000..e49b914 --- /dev/null +++ b/test-launcher/src/test/java/launcher/TestLauncher.java @@ -0,0 +1,18 @@ +package launcher; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.intuit.karate.Results; +import com.intuit.karate.Runner; + +import org.junit.jupiter.api.Test; + +class TestLauncher { + + @Test + void testParallel() { + Results results = Runner.path("classpath:launcher/features").outputCucumberJson(true).parallel(5); + assertEquals(0, results.getFailCount(), results.getErrorMessages()); + } + +} diff --git a/test-launcher/src/test/java/launcher/amazon/sqs/operations/PublishSQSMessage.java b/test-launcher/src/test/java/launcher/amazon/sqs/operations/PublishSQSMessage.java new file mode 100644 index 0000000..eac07e2 --- /dev/null +++ b/test-launcher/src/test/java/launcher/amazon/sqs/operations/PublishSQSMessage.java @@ -0,0 +1,57 @@ +package launcher.amazon.sqs.operations; + +import java.net.URI; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.GetQueueUrlRequest; +import software.amazon.awssdk.services.sqs.model.SendMessageRequest; +import software.amazon.awssdk.services.sqs.model.SqsException; + +/** + * Publishes a message to an SQS queue on a local endpoint (e.g. LocalStack). + * Returns "OK"/"KO". Exposed to Karate via Java.type. + */ +public class PublishSQSMessage { + + private final Logger logger = LoggerFactory.getLogger(PublishSQSMessage.class); + + public static class SQSData { + public String queueName; + public String message; + } + + private final SqsClient sqsClient = SqsClient.builder() + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost:4566")) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.builder() + .accountId("000000000000") + .accessKeyId("test") + .secretAccessKey("test") + .build())) + .build(); + + public String run(final SQSData sqsData) { + try { + final String queueUrl = sqsClient.getQueueUrl( + GetQueueUrlRequest.builder().queueName(sqsData.queueName).build()).queueUrl(); + + sqsClient.sendMessage(SendMessageRequest.builder() + .queueUrl(queueUrl) + .messageBody(sqsData.message) + .build()); + } catch (SqsException e) { + logger.error("Exception publishing message to SQS", e); + return "KO"; + } finally { + sqsClient.close(); + } + return "OK"; + } +} diff --git a/test-launcher/src/test/java/launcher/couchbase/CouchbaseClient.java b/test-launcher/src/test/java/launcher/couchbase/CouchbaseClient.java new file mode 100644 index 0000000..644d4bf --- /dev/null +++ b/test-launcher/src/test/java/launcher/couchbase/CouchbaseClient.java @@ -0,0 +1,66 @@ +package launcher.couchbase; + +import java.util.Map; + +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.Cluster; +import com.couchbase.client.java.Collection; +import com.couchbase.client.java.Scope; +import com.couchbase.client.java.kv.GetResult; +import com.couchbase.client.java.query.QueryResult; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.JsonPath; + +/** + * Couchbase helper for reads and N1QL queries against a running cluster. + * Exposed to Karate via Java.type. + */ +public class CouchbaseClient { + + private final Bucket bucket; + + /** + * @param config {@code connectionString}, {@code bucketName}, {@code user}, {@code password}. + */ + public CouchbaseClient(final Map config) { + final String connectionString = (String) config.get("connectionString"); + final String bucketName = (String) config.get("bucketName"); + final String user = (String) config.get("user"); + final String password = (String) config.get("password"); + + final Cluster cluster = Cluster.connect(connectionString, user, password); + this.bucket = cluster.bucket(bucketName); + } + + /** Read a JSONPath field from a document. */ + public Object getField(final String scopeName, final String collectionName, final String id, final String field) { + final Collection collection = bucket.scope(scopeName).collection(collectionName); + final Object json = Configuration.defaultConfiguration().jsonProvider() + .parse(collection.get(id).contentAsObject().toString()); + return JsonPath.read(json, field); + } + + /** Read a whole document as a JSON string. */ + public String getDocument(final String scopeName, final String collectionName, final String id) { + final Collection collection = bucket.scope(scopeName).collection(collectionName); + final GetResult getResult = collection.get(id); + return getResult.contentAsObject().toString(); + } + + /** Whether a document exists. */ + public Boolean documentExists(final String scopeName, final String collectionName, final String id) { + final Collection collection = bucket.scope(scopeName).collection(collectionName); + return collection.exists(id).exists(); + } + + /** Run a scoped N1QL query, returning the rows as a JSON string (or the error message). */ + public String executeQuery(final String query, final String bucketName, final String scopeName) { + final Scope scope = bucket.scope(scopeName); + try { + final QueryResult result = scope.query(query); + return result.rowsAsObject().toString(); + } catch (Exception e) { + return e.getMessage(); + } + } +} diff --git a/test-launcher/src/test/java/launcher/features b/test-launcher/src/test/java/launcher/features new file mode 120000 index 0000000..d5e0efb --- /dev/null +++ b/test-launcher/src/test/java/launcher/features @@ -0,0 +1 @@ +../../../../features/ \ No newline at end of file diff --git a/test-launcher/src/test/java/launcher/gcs/operations/DeleteObject.java b/test-launcher/src/test/java/launcher/gcs/operations/DeleteObject.java new file mode 100644 index 0000000..acfc6d9 --- /dev/null +++ b/test-launcher/src/test/java/launcher/gcs/operations/DeleteObject.java @@ -0,0 +1,49 @@ +package launcher.gcs.operations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.cloud.NoCredentials; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import com.google.cloud.storage.StorageOptions; + +/** + * Deletes an object from the local GCS emulator (fake-gcs-server). Returns + * whether the object was deleted. + */ +public class DeleteObject implements GcsOperation { + + private static final String GCS_SERVER = "http://localhost:9086"; + private static final Logger logger = LoggerFactory.getLogger(DeleteObject.class); + + @Override + public Object execute(final String bucket, final String filePath) { + final Storage storage = StorageOptions.newBuilder() + .setHost(GCS_SERVER) + .setProjectId("test-project") + .setCredentials(NoCredentials.getInstance()) + .build() + .getService(); + + final Blob blob = storage.get(bucket, filePath); + if (blob == null) { + logger.debug("Object [{}] not found in bucket [{}], nothing to delete", filePath, bucket); + return false; + } + + try { + final boolean result = blob.delete(); + if (result) { + logger.debug("Object [{}] deleted from bucket [{}]", filePath, bucket); + } else { + logger.debug("Object [{}] found in bucket [{}] but could not be deleted", filePath, bucket); + } + return result; + } catch (StorageException e) { + logger.error("Object [{}] found in bucket [{}] but could not be deleted", filePath, bucket, e); + return false; + } + } +} diff --git a/test-launcher/src/test/java/launcher/gcs/operations/GcsOperation.java b/test-launcher/src/test/java/launcher/gcs/operations/GcsOperation.java new file mode 100644 index 0000000..7e26156 --- /dev/null +++ b/test-launcher/src/test/java/launcher/gcs/operations/GcsOperation.java @@ -0,0 +1,5 @@ +package launcher.gcs.operations; + +public interface GcsOperation { + Object execute(String bucket, String filePath); +} diff --git a/test-launcher/src/test/java/launcher/gcs/operations/ReadObject.java b/test-launcher/src/test/java/launcher/gcs/operations/ReadObject.java new file mode 100644 index 0000000..49ce8ca --- /dev/null +++ b/test-launcher/src/test/java/launcher/gcs/operations/ReadObject.java @@ -0,0 +1,54 @@ +package launcher.gcs.operations; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.cloud.NoCredentials; +import com.google.cloud.ReadChannel; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; + +/** + * Reads an object from the local GCS emulator (fake-gcs-server). Returns the + * bytes, or null if the object does not exist. + */ +public class ReadObject implements GcsOperation { + + private static final String GCS_SERVER = "http://localhost:9086"; + private static final Logger logger = LoggerFactory.getLogger(ReadObject.class); + + @Override + public byte[] execute(final String bucket, final String filePath) { + final Storage storage = StorageOptions.newBuilder() + .setHost(GCS_SERVER) + .setProjectId("test-project") + .setCredentials(NoCredentials.getInstance()) + .build() + .getService(); + + final Blob blob = storage.get(bucket, filePath); + if (blob == null) { + logger.debug("Object [{}] not found in bucket [{}]", filePath, bucket); + return null; + } + + logger.debug("Object [{}] found in bucket [{}]", filePath, bucket); + try (ReadChannel readChannel = blob.reader()) { + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = readChannel.read(ByteBuffer.wrap(buffer))) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + return outputStream.toByteArray(); + } catch (IOException e) { + logger.error("Error reading object [{}] in bucket [{}]", filePath, bucket, e); + return null; + } + } +} diff --git a/test-launcher/src/test/java/launcher/kafka/KafkaClient.java b/test-launcher/src/test/java/launcher/kafka/KafkaClient.java new file mode 100644 index 0000000..9144bfe --- /dev/null +++ b/test-launcher/src/test/java/launcher/kafka/KafkaClient.java @@ -0,0 +1,38 @@ +package launcher.kafka; + +import java.util.Properties; + +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringSerializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Minimal Kafka producer helper. Publishes string messages to a topic on the + * local broker. Returns "OK"/"KO". Exposed to Karate via Java.type. + */ +public class KafkaClient { + + private static final String KO = "KO"; + private static final String OK = "OK"; + private static final String BOOTSTRAP_SERVERS = "localhost:9092"; + private static final Logger logger = LoggerFactory.getLogger(KafkaClient.class); + + public String publishMessage(final String topic, final String messageText) { + final Properties properties = new Properties(); + properties.setProperty(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP_SERVERS); + properties.setProperty(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + properties.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + + try (KafkaProducer producer = new KafkaProducer<>(properties)) { + producer.send(new ProducerRecord<>(topic, messageText)).get(); + logger.info("message sent to kafka successfully"); + return OK; + } catch (Exception e) { + logger.error("error sending kafka message", e); + return KO; + } + } +} diff --git a/test-launcher/src/test/java/launcher/mongo/MongoClient.java b/test-launcher/src/test/java/launcher/mongo/MongoClient.java new file mode 100644 index 0000000..a1b8596 --- /dev/null +++ b/test-launcher/src/test/java/launcher/mongo/MongoClient.java @@ -0,0 +1,67 @@ +package launcher.mongo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.bson.Document; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.MongoClients; + +/** + * MongoDB helper for seeding and asserting documents against a running MongoDB. + * Filters and documents are passed as JSON strings. Exposed to Karate via Java.type. + */ +public class MongoClient { + + private final com.mongodb.client.MongoClient client; + private final MongoDatabase database; + + /** + * @param config {@code connectionString} (e.g. mongodb://localhost:27017), {@code database}. + */ + public MongoClient(final Map config) { + final String connectionString = (String) config.get("connectionString"); + final String databaseName = (String) config.get("database"); + this.client = MongoClients.create(connectionString); + this.database = client.getDatabase(databaseName); + } + + private MongoCollection collection(final String name) { + return database.getCollection(name); + } + + /** Insert a document from a JSON string. Returns the inserted JSON. */ + public String insert(final String collection, final String json) { + final Document doc = Document.parse(json); + collection(collection).insertOne(doc); + return doc.toJson(); + } + + /** Find all documents matching a JSON filter. */ + public List find(final String collection, final String filterJson) { + final List results = new ArrayList<>(); + for (final Document doc : collection(collection).find(Document.parse(filterJson))) { + results.add(doc.toJson()); + } + return results; + } + + /** Find the first document matching a JSON filter (null if none). */ + public String findOne(final String collection, final String filterJson) { + final Document doc = collection(collection).find(Document.parse(filterJson)).first(); + return doc != null ? doc.toJson() : null; + } + + /** Whether at least one document matches the JSON filter. */ + public Boolean exists(final String collection, final String filterJson) { + return collection(collection).countDocuments(Document.parse(filterJson)) > 0; + } + + /** Delete documents matching the JSON filter. Returns the deleted count. */ + public Long delete(final String collection, final String filterJson) { + return collection(collection).deleteMany(Document.parse(filterJson)).getDeletedCount(); + } +} diff --git a/test-launcher/src/test/java/launcher/mysql/MySqlClient.java b/test-launcher/src/test/java/launcher/mysql/MySqlClient.java new file mode 100644 index 0000000..b4b695e --- /dev/null +++ b/test-launcher/src/test/java/launcher/mysql/MySqlClient.java @@ -0,0 +1,155 @@ +package launcher.mysql; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import launcher.postgres.v2.Utils; + +/** + * MySQL/MariaDB helper for seeding and asserting state against a running database. + * Mirrors {@code PostgresClient}'s API (map filters → WHERE clauses) over a JDBC + * connection of its own. Exposed to Karate via Java.type. + */ +public class MySqlClient { + + private static final Logger logger = LoggerFactory.getLogger(MySqlClient.class); + + private final String url; + private final String user; + private final String password; + + /** + * @param config {@code url} (jdbc:mysql://...), {@code user}, {@code password}. + */ + public MySqlClient(final Map config) { + this.url = (String) config.get("url"); + this.user = (String) config.get("user"); + this.password = (String) config.get("password"); + } + + private Connection open() throws SQLException { + final Connection con = DriverManager.getConnection(url, user, password); + con.setAutoCommit(true); + return con; + } + + /** Get all rows from a table matching the filter. */ + public List> getRows(final String table, final Map filter) { + final StringBuilder sb = new StringBuilder("SELECT * FROM ").append(table); + final List values = Utils.parametrizeQuery(sb, filter); + return executeParamQuery(sb.toString(), values); + } + + /** Get the first row from a table matching the filter. */ + public Map getRow(final String table, final Map filter) { + final List> rows = getRows(table, filter); + return rows.isEmpty() ? new HashMap<>() : rows.get(0); + } + + /** Get a single column value of the first row matching the filter. */ + public Object getField(final String table, final String field, final Map filter) { + final StringBuilder sb = new StringBuilder("SELECT ").append(field).append(" FROM ").append(table); + final List values = Utils.parametrizeQuery(sb, filter); + sb.append(" LIMIT 1"); + final List> results = executeParamQuery(sb.toString(), values); + return results.isEmpty() ? null : results.get(0).get(field); + } + + /** Check whether at least one row satisfies the filter. */ + public Boolean elementExists(final String table, final Map filter) { + final StringBuilder sb = new StringBuilder("SELECT count(1) as total_count FROM ").append(table); + final List values = Utils.parametrizeQuery(sb, filter); + final List> result = executeParamQuery(sb.toString(), values); + if (!result.isEmpty()) { + final Object count = result.get(0).get("total_count"); + if (count instanceof Number) { + return ((Number) count).intValue() > 0; + } + } + return false; + } + + /** Update a column for every row matching the filter. Returns true if any row changed. */ + public Boolean updateField(final String table, final String fieldToUpdate, final Object newValue, + final Map filter) { + final StringBuilder sb = new StringBuilder("UPDATE ").append(table) + .append(" SET ").append(fieldToUpdate).append(" = ? "); + final List values = Utils.parametrizeQuery(sb, filter); + values.add(0, newValue); + return executeParamUpdate(sb.toString(), values) > 0; + } + + /** Delete every row matching the filter. Returns true if any row was deleted. */ + public Boolean deleteFromTable(final String table, final Map filter) { + if (filter.isEmpty()) { + logger.warn("{deleteFromTable} called without any filter value: not allowed"); + return false; + } + final StringBuilder sb = new StringBuilder("DELETE FROM ").append(table).append(" "); + final List values = Utils.parametrizeQuery(sb, filter); + return executeParamUpdate(sb.toString(), values) > 0; + } + + /** Execute a parametrized query (? placeholders) with the given values. */ + public List> executeParamQuery(final String query, final List values) { + final List> resultRows = new ArrayList<>(); + try (Connection con = open(); PreparedStatement pst = con.prepareStatement(query)) { + for (int x = 0; x < values.size(); x++) { + pst.setObject(x + 1, values.get(x)); + } + try (ResultSet rs = pst.executeQuery()) { + final ResultSetMetaData meta = rs.getMetaData(); + while (rs.next()) { + final Map row = new HashMap<>(); + for (int i = 1; i <= meta.getColumnCount(); i++) { + row.put(meta.getColumnLabel(i), rs.getObject(i)); + } + resultRows.add(row); + } + } + } catch (Exception e) { + logger.error("{executeParamQuery} Error executing query {}", query, e); + } + return resultRows; + } + + /** Execute a SQL script (statements separated by {@code ;}). */ + public Boolean executeSqlScript(final String sql) { + try (Connection con = open(); Statement st = con.createStatement()) { + for (final String statement : sql.split(";")) { + final String query = statement + ";"; + if (!query.trim().equals(";")) { + st.executeUpdate(query); + } + } + return true; + } catch (SQLException e) { + logger.error("{executeSqlScript} Could not execute script", e); + return false; + } + } + + private int executeParamUpdate(final String query, final List values) { + try (Connection con = open(); PreparedStatement stmt = con.prepareStatement(query)) { + for (int x = 0; x < values.size(); x++) { + stmt.setObject(x + 1, values.get(x)); + } + return stmt.executeUpdate(); + } catch (SQLException e) { + logger.error("{executeParamUpdate} Error executing query {}. Values: {}.", query, values, e); + return 0; + } + } +} diff --git a/test-launcher/src/test/java/launcher/postgres/DatabaseConnectionSingleton.java b/test-launcher/src/test/java/launcher/postgres/DatabaseConnectionSingleton.java new file mode 100644 index 0000000..d3ae62b --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/DatabaseConnectionSingleton.java @@ -0,0 +1,37 @@ +package launcher.postgres; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DatabaseConnectionSingleton { + + private static volatile DatabaseConnectionSingleton instance; + + public volatile Connection connection; + + private static final Logger logger = LoggerFactory.getLogger(DatabaseConnectionSingleton.class); + + private DatabaseConnectionSingleton(final Map config) { + logger.info("Creating Database connection..."); + final String url = (String) config.get("url"); + final String user = (String) config.get("user"); + final String password = (String) config.get("password"); + try { + connection = DriverManager.getConnection(url, user, password); + connection.setAutoCommit(true); + } catch (Exception e) { + logger.error("Error connecting to database", e); + } + } + + public static synchronized DatabaseConnectionSingleton getInstance(final Map config) { + if (instance == null) { + instance = new DatabaseConnectionSingleton(config); + } + return instance; + } +} diff --git a/test-launcher/src/test/java/launcher/postgres/Executor.java b/test-launcher/src/test/java/launcher/postgres/Executor.java new file mode 100644 index 0000000..5080ac9 --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/Executor.java @@ -0,0 +1,15 @@ +package launcher.postgres; + +import java.sql.SQLException; + +public interface Executor { + + /** + * Execute a SQL script (statements separated by {@code ;}). + * + * @param scriptName optional name used in trace logs + * @param sql the SQL script + * @return true if the script executed without errors + */ + Boolean executeSqlScript(String scriptName, String sql) throws SQLException; +} diff --git a/test-launcher/src/test/java/launcher/postgres/PostgresClient.java b/test-launcher/src/test/java/launcher/postgres/PostgresClient.java new file mode 100644 index 0000000..14c6b6b --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/PostgresClient.java @@ -0,0 +1,84 @@ +package launcher.postgres; + +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +import launcher.postgres.v2.ExecutorImpl; +import launcher.postgres.v2.SelectorImpl; +import launcher.postgres.v2.UpdaterImpl; + +/** + * Facade for every interaction with a PostgreSQL database, exposed to Karate as {@code ps}. + * + *

Operations are split across {@link Selector} (reads), {@link Updater} (writes) and + * {@link Executor} (raw scripts). + * + *

Filters use a map syntax: {@code { 'column': value }} → {@code WHERE column = value}. + */ +public class PostgresClient { + + private final Selector selector; + private final Updater updater; + private final Executor executor; + + /** + * @param config connection settings: {@code url}, {@code user}, {@code password}. + */ + public PostgresClient(final Map config) { + this.selector = new SelectorImpl(config); + this.updater = new UpdaterImpl(config); + this.executor = new ExecutorImpl(config); + } + + /** Get all rows from a table matching the filter. */ + public List> getRows(final String table, final Map filter) { + return selector.getRows(table, filter); + } + + /** Get the first row from a table matching the filter. */ + public Map getRow(final String table, final Map filter) { + return selector.getRow(table, filter); + } + + /** Get all rows from a raw SQL query (parameters must be escaped). */ + public List> getRows(final String query) { + return selector.getRows(query); + } + + /** Get a single column value of the first row matching the filter. */ + public Object getField(final String table, final String field, final Map filter) { + return selector.getField(table, field, filter); + } + + /** Check whether at least one row satisfies the filter. */ + public Boolean elementExists(final String table, final Map filter) { + return selector.elementExists(table, filter); + } + + /** Update a column for every row matching the filter. Returns true if any row changed. */ + public Boolean updateField(final String table, final String fieldToUpdate, final Object newValue, + final Map filter) { + return updater.updateField(table, fieldToUpdate, newValue, filter); + } + + /** Delete every row matching the filter. Returns true if any row was deleted. */ + public Boolean deleteFromTable(final String table, final Map filter) { + return updater.deleteFromTable(table, filter); + } + + /** Execute an anonymous SQL script. */ + public Boolean executeSqlScript(final String sql) throws SQLException { + return executor.executeSqlScript(null, sql); + } + + /** Execute a named SQL script (name used in trace logs). */ + public Boolean executeSqlScript(final String scriptName, final String sql) throws SQLException { + return executor.executeSqlScript(scriptName, sql); + } + + /** Execute a parametrized query (? placeholders) with the given values. */ + public List> executeParamQuery(final String query, final List values) { + return selector.executeParamQuery(query, values); + } +} diff --git a/test-launcher/src/test/java/launcher/postgres/Selector.java b/test-launcher/src/test/java/launcher/postgres/Selector.java new file mode 100644 index 0000000..ee91c76 --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/Selector.java @@ -0,0 +1,25 @@ +package launcher.postgres; + +import java.util.List; +import java.util.Map; + +public interface Selector { + + /** Get a single row from a table applying a filter map. */ + Map getRow(String table, Map filter); + + /** Get all rows from a table applying a filter map. */ + List> getRows(String table, Map filter); + + /** Get all rows from a raw SQL query. Use as a last resort. */ + List> getRows(String query); + + /** Get a single column value of the first row matching a filter. */ + Object getField(String table, String field, Map filter); + + /** Check whether at least one row satisfies the filter. */ + Boolean elementExists(String table, Map filter); + + /** Execute a parametrized query (? placeholders) with the given values. */ + List> executeParamQuery(String query, List values); +} diff --git a/test-launcher/src/test/java/launcher/postgres/Updater.java b/test-launcher/src/test/java/launcher/postgres/Updater.java new file mode 100644 index 0000000..37e1af1 --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/Updater.java @@ -0,0 +1,20 @@ +package launcher.postgres; + +import java.util.Map; + +public interface Updater { + + /** + * Update a column for every row matching the filter. + * + * @return true if at least one row was updated. + */ + Boolean updateField(String table, String fieldToUpdate, Object newValue, Map filter); + + /** + * Delete every row matching the filter. + * + * @return true if at least one row was deleted. + */ + Boolean deleteFromTable(String table, Map filter); +} diff --git a/test-launcher/src/test/java/launcher/postgres/v2/DBConnector.java b/test-launcher/src/test/java/launcher/postgres/v2/DBConnector.java new file mode 100644 index 0000000..c31b1b1 --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/v2/DBConnector.java @@ -0,0 +1,84 @@ +package launcher.postgres.v2; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import launcher.postgres.DatabaseConnectionSingleton; + +/** + * Base class providing parametrized query/update execution against PostgreSQL. + */ +public abstract class DBConnector { + + protected final Map config; + + private static final Logger logger = LoggerFactory.getLogger(DBConnector.class); + + protected DBConnector(final Map config) { + this.config = config; + } + + /** + * Execute a parametrized query (? placeholders) and return the rows as maps. + */ + public List> executeParamQuery(final String query, final List values) { + final Connection con = DatabaseConnectionSingleton.getInstance(config).connection; + final List> resultRows = new ArrayList<>(); + + try (PreparedStatement pst = con.prepareStatement(query)) { + for (int x = 0; x < values.size(); x++) { + pst.setObject(x + 1, values.get(x)); + } + try (ResultSet rs = pst.executeQuery()) { + final ResultSetMetaData meta = rs.getMetaData(); + while (rs.next()) { + final Map row = new HashMap<>(); + for (int i = 1; i <= meta.getColumnCount(); i++) { + row.put(meta.getColumnLabel(i), rs.getObject(i)); + } + resultRows.add(row); + } + } + } catch (Exception e) { + logger.error("{executeParamQuery} Error executing query {}", query, e); + } + return resultRows; + } + + /** + * Execute a raw SELECT query with no parameters. + */ + protected List> executeQuery(final String query) { + return executeParamQuery(query, new ArrayList<>()); + } + + /** + * Execute a parametrized UPDATE/DELETE and return the number of affected rows. + */ + protected int executeParamUpdate(final String query, final List values) { + int numRowsAffected = 0; + final Connection con = DatabaseConnectionSingleton.getInstance(config).connection; + try (PreparedStatement stmt = con.prepareStatement(query)) { + for (int x = 0; x < values.size(); x++) { + stmt.setObject(x + 1, values.get(x)); + } + numRowsAffected = stmt.executeUpdate(); + if (numRowsAffected == 0) { + logger.warn("{executeParamUpdate} 0 rows affected. Query: {}. Values: {}.", query, values); + } + } catch (SQLException e) { + logger.error("{executeParamUpdate} Error executing query {}. Values: {}.", query, values, e); + } + return numRowsAffected; + } +} diff --git a/test-launcher/src/test/java/launcher/postgres/v2/ExecutorImpl.java b/test-launcher/src/test/java/launcher/postgres/v2/ExecutorImpl.java new file mode 100644 index 0000000..272908c --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/v2/ExecutorImpl.java @@ -0,0 +1,49 @@ +package launcher.postgres.v2; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import launcher.postgres.DatabaseConnectionSingleton; +import launcher.postgres.Executor; + +public class ExecutorImpl extends DBConnector implements Executor { + + private final Logger logger = LoggerFactory.getLogger(ExecutorImpl.class); + + public ExecutorImpl(final Map config) { + super(config); + } + + @Override + public Boolean executeSqlScript(final String scriptName, final String sql) throws SQLException { + final Connection con = DatabaseConnectionSingleton.getInstance(config).connection; + final String[] statements = sql.split(";"); + boolean ok = true; + String query = ""; + + try (Statement st = con.createStatement()) { + for (final String statement : statements) { + query = statement + ";"; + if (!query.trim().equals(";")) { + st.executeUpdate(query); + } + query = ""; + } + } catch (SQLException e) { + final String name = scriptName != null ? scriptName : ""; + logger.error("{executeSqlScript} Could not execute script {}. Error executing query {}.", name, query, e); + try { + con.rollback(); + } catch (SQLException re) { + logger.warn("{executeSqlScript} could not rollback after error.", re); + } + ok = false; + } + return ok; + } +} diff --git a/test-launcher/src/test/java/launcher/postgres/v2/SelectorImpl.java b/test-launcher/src/test/java/launcher/postgres/v2/SelectorImpl.java new file mode 100644 index 0000000..7fdecc0 --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/v2/SelectorImpl.java @@ -0,0 +1,55 @@ +package launcher.postgres.v2; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import launcher.postgres.Selector; + +public class SelectorImpl extends DBConnector implements Selector { + + public SelectorImpl(final Map config) { + super(config); + } + + @Override + public Map getRow(final String table, final Map filter) { + final List> rows = getRows(table, filter); + return rows.isEmpty() ? new HashMap<>() : rows.get(0); + } + + @Override + public List> getRows(final String table, final Map filter) { + final StringBuilder sb = new StringBuilder("SELECT * FROM ").append(table); + final List values = Utils.parametrizeQuery(sb, filter); + return executeParamQuery(sb.toString(), values); + } + + @Override + public List> getRows(final String query) { + return executeQuery(query); + } + + @Override + public Object getField(final String table, final String field, final Map filter) { + final StringBuilder sb = new StringBuilder("SELECT ").append(field).append(" FROM ").append(table); + final List values = Utils.parametrizeQuery(sb, filter); + sb.append(" LIMIT 1"); + final List> results = executeParamQuery(sb.toString(), values); + return results.isEmpty() ? null : results.get(0).get(field); + } + + @Override + public Boolean elementExists(final String table, final Map filter) { + final StringBuilder sb = new StringBuilder("SELECT count(1) as total_count FROM ").append(table); + final List values = Utils.parametrizeQuery(sb, filter); + final List> result = executeParamQuery(sb.toString(), values); + if (!result.isEmpty()) { + final Object count = result.get(0).get("total_count"); + if (count instanceof Number) { + return ((Number) count).intValue() > 0; + } + } + return false; + } +} diff --git a/test-launcher/src/test/java/launcher/postgres/v2/UpdaterImpl.java b/test-launcher/src/test/java/launcher/postgres/v2/UpdaterImpl.java new file mode 100644 index 0000000..3796e64 --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/v2/UpdaterImpl.java @@ -0,0 +1,40 @@ +package launcher.postgres.v2; + +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import launcher.postgres.Updater; + +public class UpdaterImpl extends DBConnector implements Updater { + + private final Logger logger = LoggerFactory.getLogger(UpdaterImpl.class); + + public UpdaterImpl(final Map config) { + super(config); + } + + @Override + public Boolean updateField(final String table, final String fieldToUpdate, final Object newValue, + final Map filter) { + final StringBuilder sb = new StringBuilder("UPDATE ").append(table) + .append(" SET ").append(fieldToUpdate).append(" = ? "); + final List values = Utils.parametrizeQuery(sb, filter); + // newValue binds to the first placeholder (the SET clause) + values.add(0, newValue); + return executeParamUpdate(sb.toString(), values) > 0; + } + + @Override + public Boolean deleteFromTable(final String table, final Map filter) { + if (filter.isEmpty()) { + logger.warn("{deleteFromTable} called without any filter value: not allowed"); + return false; + } + final StringBuilder sb = new StringBuilder("DELETE FROM ").append(table).append(" "); + final List values = Utils.parametrizeQuery(sb, filter); + return executeParamUpdate(sb.toString(), values) > 0; + } +} diff --git a/test-launcher/src/test/java/launcher/postgres/v2/Utils.java b/test-launcher/src/test/java/launcher/postgres/v2/Utils.java new file mode 100644 index 0000000..ecaadc7 --- /dev/null +++ b/test-launcher/src/test/java/launcher/postgres/v2/Utils.java @@ -0,0 +1,27 @@ +package launcher.postgres.v2; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class Utils { + + private Utils() { + } + + /** + * Appends the filter part of a query ({@code WHERE k = ? AND ...}) to the StringBuilder and + * returns the ordered list of values to bind to the placeholders. + */ + public static List parametrizeQuery(final StringBuilder sb, final Map filter) { + boolean first = true; + final List values = new ArrayList<>(); + for (final Map.Entry entry : filter.entrySet()) { + sb.append(first ? " WHERE " : " AND "); + first = false; + sb.append(entry.getKey()).append(" = ? "); + values.add(entry.getValue()); + } + return values; + } +} diff --git a/test-launcher/src/test/java/launcher/pubsub/PubSub.java b/test-launcher/src/test/java/launcher/pubsub/PubSub.java new file mode 100644 index 0000000..7270984 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/PubSub.java @@ -0,0 +1,102 @@ +package launcher.pubsub; + +import java.util.List; +import java.util.Map; + +import com.google.api.client.util.Strings; +import com.google.api.gax.core.CredentialsProvider; +import com.google.api.gax.core.NoCredentialsProvider; +import com.google.api.gax.grpc.GrpcTransportChannel; +import com.google.api.gax.rpc.FixedTransportChannelProvider; +import com.google.api.gax.rpc.TransportChannelProvider; +import com.google.cloud.pubsub.v1.Publisher; +import com.google.cloud.pubsub.v1.SubscriptionAdminClient; +import com.google.cloud.pubsub.v1.SubscriptionAdminSettings; +import com.google.cloud.pubsub.v1.TopicAdminClient; +import com.google.cloud.pubsub.v1.TopicAdminSettings; +import com.google.pubsub.v1.TopicName; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import launcher.pubsub.operations.PubSubOperation; + +/** + * Runs Pub/Sub operations against the local emulator (host:port from + * {@code PUBSUB_EMULATOR_HOST}). Exposed to Karate as {@code Java.type('launcher.pubsub.PubSub')}. + */ +public class PubSub { + + public static class PubSubData { + public String projectId; + public String subscriptionId; + public String topicId; + public String message; + public String expectedMessage; + public String ceType; + public String ceSource; + public List ignoreMessageFields; + public List orderedMessages; + public String orderingKey; + public Integer maxRequestedMessages; + public Integer timeoutConsumeMessageSeconds; + public Map attributes; + public TopicName topicName; + public TopicAdminClient topicClient; + public SubscriptionAdminClient subscriptionClient; + public Publisher publisher; + public TransportChannelProvider channelProvider; + } + + /** + * Runs an operation, propagating any exception (use in debug/strict scenarios). + */ + public String runOperationOnEmulatorDebug(final PubSubOperation operation, final PubSubData data) throws Exception { + final String hostport = System.getenv("PUBSUB_EMULATOR_HOST"); + final ManagedChannel channel = ManagedChannelBuilder.forTarget(hostport).usePlaintext().build(); + try { + wire(data, channel); + return operation.execute(data); + } finally { + channel.shutdown(); + } + } + + /** + * Runs an operation, returning "KO" on any failure. + */ + public String runOperationOnEmulator(final PubSubOperation operation, final PubSubData data) throws Exception { + final String hostport = System.getenv("PUBSUB_EMULATOR_HOST"); + final ManagedChannel channel = ManagedChannelBuilder.forTarget(hostport).usePlaintext().build(); + try { + wire(data, channel); + return operation.execute(data); + } catch (Exception e) { + return "KO"; + } finally { + channel.shutdown(); + } + } + + private void wire(final PubSubData data, final ManagedChannel channel) throws Exception { + final TransportChannelProvider channelProvider = + FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); + final CredentialsProvider credentialsProvider = NoCredentialsProvider.create(); + + data.topicClient = TopicAdminClient.create(TopicAdminSettings.newBuilder() + .setTransportChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider).build()); + + data.subscriptionClient = SubscriptionAdminClient.create(SubscriptionAdminSettings.newBuilder() + .setTransportChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider).build()); + + data.topicName = TopicName.of(data.projectId, data.topicId); + + data.publisher = Publisher.newBuilder(data.topicName) + .setChannelProvider(channelProvider) + .setCredentialsProvider(credentialsProvider) + .setEnableMessageOrdering(!Strings.isNullOrEmpty(data.orderingKey)).build(); + + data.channelProvider = channelProvider; + } +} diff --git a/test-launcher/src/test/java/launcher/pubsub/Utils.java b/test-launcher/src/test/java/launcher/pubsub/Utils.java new file mode 100644 index 0000000..a3c11f3 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/Utils.java @@ -0,0 +1,157 @@ +package launcher.pubsub; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.api.client.util.Strings; +import com.google.pubsub.v1.PubsubMessage; +import com.google.pubsub.v1.ReceivedMessage; + +import launcher.pubsub.PubSub.PubSubData; + +/** + * Static helpers for Pub/Sub message handling (JSON mapping, ordering and attribute checks). + */ +public final class Utils { + + private Utils() { + } + + private static final Logger logger = LoggerFactory.getLogger(Utils.class); + + /** Maps a message to a JsonNode, removing {@link PubSubData#ignoreMessageFields}. */ + public static JsonNode mapperMessageToJsonNode(final String message, final PubSubData pubSubData) { + final Set ignoreMessageFields = new HashSet<>(); + if (pubSubData.ignoreMessageFields != null) { + ignoreMessageFields.addAll(pubSubData.ignoreMessageFields); + } + return mapperMessageToJsonNode(message, ignoreMessageFields); + } + + /** Maps a message to a JsonNode, removing the given fields. */ + public static JsonNode mapperMessageToJsonNode(final String message, final Set ignoreMessageFields) { + final ObjectMapper mapper = new ObjectMapper(); + try { + final JsonNode jsonNode = mapper.readTree(message); + if (ignoreMessageFields != null && !ignoreMessageFields.isEmpty()) { + ignoreMessageFields.forEach(field -> deleteField(jsonNode, field)); + } + return jsonNode; + } catch (JsonProcessingException jex) { + throw new RuntimeException("Error parsing message: " + message, jex); + } + } + + /** Maps a message to a Map, removing {@link PubSubData#ignoreMessageFields}. */ + @SuppressWarnings("unchecked") + public static Map mapperMessageToMap(final String message, final PubSubData pubSubData) { + final ObjectMapper mapper = new ObjectMapper(); + final JsonNode jsonNode = mapperMessageToJsonNode(message, pubSubData); + try { + return mapper.readValue(jsonNode.toString(), Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("Error parsing message: " + message, e); + } + } + + /** Removes a field (supports nested {@code parent.child} paths) from a JsonNode. */ + public static void deleteField(final JsonNode node, final String fieldName) { + if (node.isNull()) { + return; + } + if (node.isArray()) { + for (final JsonNode item : node) { + deleteField(item, fieldName); + } + return; + } + final int position = fieldName.indexOf('.'); + if (position < 0) { + ((ObjectNode) node).remove(fieldName); + } else { + final JsonNode childNode = node.get(fieldName.substring(0, position)); + if (childNode == null) { + logger.warn("Error removing the field {}", fieldName); + } else { + deleteField(childNode, fieldName.substring(position + 1)); + } + } + } + + public static boolean isValidOrdering(final PubSubData pubSubData, final ReceivedMessage message) { + return isValidOrdering(pubSubData, message.getMessage()); + } + + public static boolean isValidOrdering(final PubSubData pubSubData, final PubsubMessage message) { + boolean valid = true; + if (!Strings.isNullOrEmpty(pubSubData.orderingKey)) { + valid = pubSubData.orderingKey.equals(message.getOrderingKey()); + } + return valid; + } + + public static boolean containsAttributes(final PubSubData pubSubData, final ReceivedMessage message) { + return containsAttributes(pubSubData, message.getMessage()); + } + + public static boolean containsAttributes(final PubSubData pubSubData, final PubsubMessage message) { + final Map pubSubDataAttributes = pubSubData.attributes; + final Map pubSubMsgAttributes = message.getAttributesMap(); + boolean ret = true; + if (pubSubDataAttributes != null && !pubSubDataAttributes.isEmpty()) { + for (final Iterator> it = pubSubDataAttributes.entrySet().iterator(); + it.hasNext() && !ret;) { + final Map.Entry attr = it.next(); + final String messageAttributeValue = pubSubMsgAttributes.get(attr.getKey()); + ret = messageAttributeValue == null || !messageAttributeValue.equals(attr.getValue()); + } + } + return ret; + } + + public static boolean compareJsonNodes(final JsonNode expectedNode, final JsonNode node) { + if (expectedNode.isNull() || expectedNode.isMissingNode() || expectedNode.equals(node)) { + return true; + } + if (expectedNode.isArray() && node.isArray()) { + return compareArrays((ArrayNode) expectedNode, (ArrayNode) node); + } + if (expectedNode.isObject() && node.isObject()) { + if (expectedNode.size() != node.size()) { + return false; + } + final Iterator fieldNames = expectedNode.fieldNames(); + while (fieldNames.hasNext()) { + final String fieldName = fieldNames.next(); + if (!compareJsonNodes(expectedNode.get(fieldName), node.get(fieldName))) { + return false; + } + } + return true; + } + return false; + } + + private static boolean compareArrays(final ArrayNode expectedArrayNode, final ArrayNode arrayNode) { + return convertArrayNodeToSet(expectedArrayNode).equals(convertArrayNodeToSet(arrayNode)); + } + + private static Set convertArrayNodeToSet(final ArrayNode arrayNode) { + final Set set = new HashSet<>(); + final Iterator elements = arrayNode.elements(); + while (elements.hasNext()) { + set.add(elements.next()); + } + return set; + } +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeMessage.java b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeMessage.java new file mode 100644 index 0000000..c62ba06 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeMessage.java @@ -0,0 +1,144 @@ +package launcher.pubsub.operations; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.api.gax.core.NoCredentialsProvider; +import com.google.api.gax.grpc.GrpcTransportChannel; +import com.google.api.gax.rpc.FixedTransportChannelProvider; +import com.google.cloud.pubsub.v1.AckReplyConsumer; +import com.google.cloud.pubsub.v1.MessageReceiver; +import com.google.cloud.pubsub.v1.Subscriber; +import com.google.pubsub.v1.AcknowledgeRequest; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PubsubMessage; +import com.google.pubsub.v1.PullRequest; +import com.google.pubsub.v1.PullResponse; +import com.google.pubsub.v1.ReceivedMessage; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import launcher.pubsub.Utils; +import launcher.pubsub.PubSub.PubSubData; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.awaitility.Awaitility.await; + +public class ConsumeMessage implements PubSubOperation { + + private static final Logger logger = LoggerFactory.getLogger(ConsumeMessage.class); + + public String execute(PubSubData pubSubData) { + if (Objects.nonNull(pubSubData.timeoutConsumeMessageSeconds)) { + return executeWithTimeout(pubSubData); + } else { + return executeWithoutTimeout(pubSubData); + } + } + + private String executeWithTimeout(PubSubData pubSubData) { + + AtomicBoolean messageFound = new AtomicBoolean(false); + ProjectSubscriptionName subscriptionName = + ProjectSubscriptionName.of(pubSubData.projectId, pubSubData.subscriptionId); + + JsonNode expectedMessageTree = Utils.mapperMessageToJsonNode(pubSubData.expectedMessage, pubSubData); + + String hostPort = System.getenv("PUBSUB_EMULATOR_HOST"); + ManagedChannel channel = ManagedChannelBuilder.forTarget(hostPort).usePlaintext().build(); + FixedTransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); + + MessageReceiver receiver = + (PubsubMessage message, AckReplyConsumer consumer) -> { + JsonNode messageTree = Utils.mapperMessageToJsonNode(message.getData().toStringUtf8(), + pubSubData); + if (Utils.compareJsonNodes(expectedMessageTree, messageTree) && + Utils.isValidOrdering(pubSubData, message) && + Utils.containsAttributes(pubSubData, message)) { + consumer.ack(); + messageFound.set(true); + } + }; + + Subscriber subscriber = null; + try { + subscriber = Subscriber.newBuilder(subscriptionName, receiver) + .setCredentialsProvider(NoCredentialsProvider.create()) + .setChannelProvider(channelProvider).build(); + subscriber.startAsync(); + + await().atMost(pubSubData.timeoutConsumeMessageSeconds, TimeUnit.SECONDS) + .until(messageFound::get); + + } catch (Exception e) { + logger.error("error consuming messages", e); + } finally { + logger.info("finally consuming messages, stopping subscriber"); + Objects.requireNonNull(subscriber).stopAsync(); + } + + if (!messageFound.get()) { + throw new RuntimeException("message to consume not found"); + } + + logger.info("message to consume found successfully"); + return "OK"; + } + + private String executeWithoutTimeout(PubSubData pubSubData) { + logger.info("executing ConsumeMessage without timeout"); + String subscriptionName = ProjectSubscriptionName.format(pubSubData.projectId, pubSubData.subscriptionId); + int numberMessages = Optional.ofNullable(pubSubData.maxRequestedMessages).orElse(MAX_REQUESTED_MESSAGES); + PullRequest pullRequest = PullRequest.newBuilder().setMaxMessages(numberMessages) + .setSubscription(subscriptionName) + .build(); + + // Use pullCallable().futureCall to asynchronously perform this operation. + PullResponse pullResponse = pubSubData.subscriptionClient.pullCallable().call(pullRequest); + List messages = pullResponse.getReceivedMessagesList(); + + if (messages.isEmpty()) { + throw new RuntimeException("No message to consume"); + } + + JsonNode expectedMessageTree = Utils.mapperMessageToJsonNode(pubSubData.expectedMessage, pubSubData); + + boolean notFoundMessage = true; + for (ReceivedMessage message : messages) { + JsonNode messageTree = Utils.mapperMessageToJsonNode(message.getMessage().getData().toStringUtf8(), + pubSubData); + + // Handle received message + if (notFoundMessage && Utils.compareJsonNodes(expectedMessageTree, messageTree) + && Utils.isValidOrdering(pubSubData, message) + && Utils.containsAttributes(pubSubData, message)) { + notFoundMessage = false; + // Acknowledge received messages. + AcknowledgeRequest acknowledgeRequest = AcknowledgeRequest.newBuilder() + .setSubscription(subscriptionName) + .addAckIds(message.getAckId()).build(); + + // Use acknowledgeCallable().futureCall to asynchronously perform this + // operation. + pubSubData.subscriptionClient.acknowledgeCallable().call(acknowledgeRequest); + logger.info("message: {} consumed correctly", message); + } else { + // Nack message + pubSubData.subscriptionClient.modifyAckDeadline(subscriptionName, Collections.singletonList(message.getAckId()), + 0); + } + } + if (notFoundMessage) { + throw new RuntimeException("Message to consume not found"); + } + + return "OK"; + } + +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeMessageDebug.java b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeMessageDebug.java new file mode 100644 index 0000000..f1cf023 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeMessageDebug.java @@ -0,0 +1,82 @@ +package launcher.pubsub.operations; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.pubsub.v1.AcknowledgeRequest; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PullRequest; +import com.google.pubsub.v1.PullResponse; +import com.google.pubsub.v1.ReceivedMessage; +import launcher.pubsub.Utils; +import launcher.pubsub.PubSub.PubSubData; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConsumeMessageDebug implements PubSubOperation { + + private final Logger logger = LoggerFactory.getLogger(ConsumeMessageDebug.class); + + public String execute(PubSubData pubSubData) throws JsonProcessingException { + + String log = ""; + String subscriptionName = ProjectSubscriptionName.format(pubSubData.projectId, pubSubData.subscriptionId); + int numberMessages = Optional.ofNullable(pubSubData.maxRequestedMessages).orElse(MAX_REQUESTED_MESSAGES); + PullRequest pullRequest = PullRequest.newBuilder().setMaxMessages(numberMessages) + .setSubscription(subscriptionName) + .build(); + + // Use pullCallable().futureCall to asynchronously perform this operation. + PullResponse pullResponse = pubSubData.subscriptionClient.pullCallable().call(pullRequest); + List messages = pullResponse.getReceivedMessagesList(); + + if (messages.isEmpty()) { + throw new RuntimeException("No message to consume"); + } + + JsonNode expectedMessageTree = Utils.mapperMessageToJsonNode(pubSubData.expectedMessage, pubSubData); + log = log + expectedMessageTree.toPrettyString() + "\n"; + + boolean notFoundMessage = true; + + log = log + "****************************************************************" + "\n"; + + for (ReceivedMessage message : messages) { + JsonNode messageTree = Utils.mapperMessageToJsonNode(message.getMessage().getData().toStringUtf8(), + pubSubData); + + log = log + "New message was found! \n"; + log = log + messageTree.toPrettyString() + "\n"; + log = log + "************************" + expectedMessageTree.equals(messageTree) + "****************************************" + "\n"; + // Handle received message + if (notFoundMessage && Utils.compareJsonNodes(expectedMessageTree, messageTree) + && Utils.isValidOrdering(pubSubData, message) + && Utils.containsAttributes(pubSubData, message)) { + notFoundMessage = false; + // Acknowledge received messages. + AcknowledgeRequest acknowledgeRequest = AcknowledgeRequest.newBuilder() + .setSubscription(subscriptionName) + .addAckIds(message.getAckId()).build(); + + // Use acknowledgeCallable().futureCall to asynchronously perform this + // operation. + pubSubData.subscriptionClient.acknowledgeCallable().call(acknowledgeRequest); + + logger.info("Message: {} consumed correctly", message); + } else { + // Nack message + pubSubData.subscriptionClient.modifyAckDeadline(subscriptionName, Arrays.asList(message.getAckId()), + 0); + } + } + if (notFoundMessage) { + throw new RuntimeException("Log de mensajes: [" + log + "]. Message to consume not found"); + } + return "OK"; + } + +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeOrderedMessages.java b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeOrderedMessages.java new file mode 100644 index 0000000..c69a71d --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeOrderedMessages.java @@ -0,0 +1,89 @@ +package launcher.pubsub.operations; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.pubsub.v1.AcknowledgeRequest; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PullRequest; +import com.google.pubsub.v1.PullResponse; +import com.google.pubsub.v1.ReceivedMessage; +import launcher.pubsub.Utils; +import launcher.pubsub.PubSub.PubSubData; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConsumeOrderedMessages implements PubSubOperation { + + private final Logger logger = LoggerFactory.getLogger(ConsumeOrderedMessages.class); + + public String execute(PubSubData pubSubData) throws JsonProcessingException { + String subscriptionName = ProjectSubscriptionName.format(pubSubData.projectId, pubSubData.subscriptionId); + + int numberMessages = Optional.ofNullable(pubSubData.maxRequestedMessages).orElse(MAX_REQUESTED_MESSAGES); + PullRequest pullRequest = PullRequest.newBuilder().setMaxMessages(numberMessages) + .setSubscription(subscriptionName) + .build(); + + // Use pullCallable().futureCall to asynchronously perform this operation. + PullResponse pullResponse = pubSubData.subscriptionClient.pullCallable().call(pullRequest); + List messages = pullResponse.getReceivedMessagesList(); + + if (messages.isEmpty()) { + throw new RuntimeException("No message to consume"); + } + + List expectedMessagesTree = pubSubData.orderedMessages.stream() + .map(m -> Utils.mapperMessageToJsonNode(m, pubSubData)) + .collect(Collectors.toList()); + + List receivedMessagesTree = new ArrayList<>(); + + messages.stream() + .forEach(message -> { + if (pubSubData.orderingKey.equals(message.getMessage().getOrderingKey())) { + logger.info("Group ordered message for acknowledgement is {}.", message.getMessage().getData().toStringUtf8()); + AcknowledgeRequest acknowledgeRequest = AcknowledgeRequest.newBuilder() + .setSubscription(subscriptionName) + .addAckIds(message.getAckId()).build(); + // pubSubData.subscriptionClient.acknowledgeCallable().call(acknowledgeRequest); + pubSubData.subscriptionClient.acknowledge(acknowledgeRequest); + + receivedMessagesTree.add( + Utils.mapperMessageToJsonNode(message.getMessage().getData().toStringUtf8(), pubSubData)); + } else { + // Nack message + pubSubData.subscriptionClient.modifyAckDeadline(subscriptionName, + Arrays.asList(message.getAckId()), 0); + } + }); + + if (expectedMessagesTree.size() != receivedMessagesTree.size()) { + throw new RuntimeException(String.format("Number of messages is invalid. Expected: %d - Received: %d", + expectedMessagesTree.size(), receivedMessagesTree.size())); + } + + boolean isOk = true; + for (int i = 0; i < expectedMessagesTree.size(); i++) { + if (!expectedMessagesTree.get(i).equals(receivedMessagesTree.get(i))) { + isOk = false; + break; + } + } + + if (isOk) { + logger.info("{} messages consumed correctly", expectedMessagesTree.size()); + } else { + throw new RuntimeException("Received messages have a different order than expected ones"); + } + + return "OK"; + } + +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeOrderedMessagesDebug.java b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeOrderedMessagesDebug.java new file mode 100644 index 0000000..7025458 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/ConsumeOrderedMessagesDebug.java @@ -0,0 +1,92 @@ +package launcher.pubsub.operations; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.pubsub.v1.AcknowledgeRequest; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PullRequest; +import com.google.pubsub.v1.PullResponse; +import com.google.pubsub.v1.ReceivedMessage; +import launcher.pubsub.Utils; +import launcher.pubsub.PubSub.PubSubData; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConsumeOrderedMessagesDebug implements PubSubOperation { + + private final Logger logger = LoggerFactory.getLogger(ConsumeOrderedMessagesDebug.class); + + public String execute(PubSubData pubSubData) throws JsonProcessingException { + String subscriptionName = ProjectSubscriptionName.format(pubSubData.projectId, pubSubData.subscriptionId); + + int numberMessages = Optional.ofNullable(pubSubData.maxRequestedMessages).orElse(MAX_REQUESTED_MESSAGES); + PullRequest pullRequest = PullRequest.newBuilder().setMaxMessages(numberMessages) + .setSubscription(subscriptionName) + .build(); + + // Use pullCallable().futureCall to asynchronously perform this operation. + PullResponse pullResponse = pubSubData.subscriptionClient.pullCallable().call(pullRequest); + List messages = pullResponse.getReceivedMessagesList(); + + if (messages.isEmpty()) { + throw new RuntimeException("No message to consume"); + } + + List expectedMessagesTree = pubSubData.orderedMessages.stream() + .map(m -> Utils.mapperMessageToJsonNode(m, pubSubData)) + .collect(Collectors.toList()); + + List receivedMessagesTree = new ArrayList<>(); + + messages.stream() + .forEach(message -> { + + logger.info("New message was found! \n{}\n****************************************************"); + + if (pubSubData.orderingKey.equals(message.getMessage().getOrderingKey())) { + logger.info(" Group ordered message for acknowledgement is {}", message.getMessage().getData().toStringUtf8()); + AcknowledgeRequest acknowledgeRequest = AcknowledgeRequest.newBuilder() + .setSubscription(subscriptionName) + .addAckIds(message.getAckId()).build(); + // pubSubData.subscriptionClient.acknowledgeCallable().call(acknowledgeRequest); + pubSubData.subscriptionClient.acknowledge(acknowledgeRequest); + + receivedMessagesTree.add( + Utils.mapperMessageToJsonNode(message.getMessage().getData().toStringUtf8(), pubSubData)); + } else { + // Nack message + pubSubData.subscriptionClient.modifyAckDeadline(subscriptionName, + Arrays.asList(message.getAckId()), 0); + } + }); + + if (expectedMessagesTree.size() != receivedMessagesTree.size()) { + throw new RuntimeException(String.format("Number of messages is invalid. Expected: %d - Received: %d", + expectedMessagesTree.size(), receivedMessagesTree.size())); + } + + boolean isOk = true; + for (int i = 0; i < expectedMessagesTree.size(); i++) { + if (!expectedMessagesTree.get(i).equals(receivedMessagesTree.get(i))) { + isOk = false; + break; + } + } + + if (isOk) { + logger.info("{} messages consumed correctly", expectedMessagesTree.size()); + } else { + throw new RuntimeException("Received messages have a different order than expected ones"); + } + + return "OK"; + } + +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/CreateSubscription.java b/test-launcher/src/test/java/launcher/pubsub/operations/CreateSubscription.java new file mode 100644 index 0000000..768b183 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/CreateSubscription.java @@ -0,0 +1,31 @@ +package launcher.pubsub.operations; + +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PushConfig; +import com.google.pubsub.v1.Subscription; +import com.google.pubsub.v1.TopicName; + +import launcher.pubsub.PubSub.PubSubData; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CreateSubscription implements PubSubOperation { + + private final Logger logger = LoggerFactory.getLogger(CreateSubscription.class); + + public String execute(PubSubData pubSubData) throws IOException { + ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.of(pubSubData.projectId, + pubSubData.subscriptionId); + // Create a pull subscription with default acknowledgement deadline of 10 + // seconds. + // Messages not successfully acknowledged within 10 seconds will get resent by + // the server. + Subscription subscription = pubSubData.subscriptionClient.createSubscription(subscriptionName, + TopicName.of(pubSubData.projectId, pubSubData.topicId), PushConfig.getDefaultInstance(), 10); + logger.info("Created pull subscription: {}", subscription.getName()); + return "OK"; + } +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/CreateTopic.java b/test-launcher/src/test/java/launcher/pubsub/operations/CreateTopic.java new file mode 100644 index 0000000..2936b8a --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/CreateTopic.java @@ -0,0 +1,20 @@ +package launcher.pubsub.operations; + +import com.google.pubsub.v1.Topic; +import launcher.pubsub.PubSub.PubSubData; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CreateTopic implements PubSubOperation { + + private final Logger logger = LoggerFactory.getLogger(CreateTopic.class); + + public String execute(PubSubData pubSubData) throws IOException { + Topic topic = pubSubData.topicClient.createTopic(pubSubData.topicName); + logger.info("Created topic: {}", topic.getName()); + return "OK"; + } +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/FindMessage.java b/test-launcher/src/test/java/launcher/pubsub/operations/FindMessage.java new file mode 100644 index 0000000..6df49d5 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/FindMessage.java @@ -0,0 +1,82 @@ +package launcher.pubsub.operations; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.pubsub.v1.AcknowledgeRequest; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PullRequest; +import com.google.pubsub.v1.PullResponse; +import com.google.pubsub.v1.ReceivedMessage; +import launcher.pubsub.Utils; +import launcher.pubsub.PubSub.PubSubData; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FindMessage implements PubSubOperation { + + private final Logger logger = LoggerFactory.getLogger(FindMessage.class); + + public String execute(PubSubData pubSubData) throws JsonProcessingException { + + String log = ""; + String responseMessage = null; + String subscriptionName = ProjectSubscriptionName.format(pubSubData.projectId, pubSubData.subscriptionId); + int numberMessages = Optional.ofNullable(pubSubData.maxRequestedMessages).orElse(MAX_REQUESTED_MESSAGES); + PullRequest pullRequest = PullRequest.newBuilder().setMaxMessages(numberMessages) + .setSubscription(subscriptionName) + .build(); + + // Use pullCallable().futureCall to asynchronously perform this operation. + PullResponse pullResponse = pubSubData.subscriptionClient.pullCallable().call(pullRequest); + List messages = pullResponse.getReceivedMessagesList(); + + if (messages.isEmpty()) { + throw new RuntimeException("No message to consume"); + } + + JsonNode expectedMessageTree = Utils.mapperMessageToJsonNode(pubSubData.expectedMessage, pubSubData); + log = log + expectedMessageTree.toString() + "\n"; + + boolean notFoundMessage = true; + + log = log + "****************************************************************" + "\n"; + for (ReceivedMessage message : messages) { + + String stringMessage = message.getMessage().getData().toStringUtf8(); + + JsonNode messageTree = Utils.mapperMessageToJsonNode(stringMessage, pubSubData); + log = log + "New message was found! \n"; + log = log + messageTree.toString() + "\n"; + log = log + "************************" + expectedMessageTree.equals(messageTree) + "****************************************" + "\n"; + // Handle received message + if (notFoundMessage && expectedMessageTree.equals(messageTree) + && Utils.isValidOrdering(pubSubData, message) + && Utils.containsAttributes(pubSubData, message)) { + notFoundMessage = false; + // Acknowledge received messages. + AcknowledgeRequest acknowledgeRequest = AcknowledgeRequest.newBuilder() + .setSubscription(subscriptionName) + .addAckIds(message.getAckId()).build(); + + // Use acknowledgeCallable().futureCall to asynchronously perform this + // operation. + pubSubData.subscriptionClient.acknowledgeCallable().call(acknowledgeRequest); + + logger.info("message: {} consumed correctly", message); + responseMessage = stringMessage; + } else { + // Nack message + pubSubData.subscriptionClient.modifyAckDeadline(subscriptionName, Arrays.asList(message.getAckId()), + 0); + } + } + + return responseMessage; + } + +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/FindMessageAsync.java b/test-launcher/src/test/java/launcher/pubsub/operations/FindMessageAsync.java new file mode 100644 index 0000000..43ad520 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/FindMessageAsync.java @@ -0,0 +1,84 @@ +package launcher.pubsub.operations; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.api.gax.core.NoCredentialsProvider; +import com.google.cloud.pubsub.v1.AckReplyConsumer; +import com.google.cloud.pubsub.v1.Subscriber; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PubsubMessage; + +import launcher.pubsub.PubSub.PubSubData; +import launcher.pubsub.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public class FindMessageAsync implements PubSubOperation { + + private static final Logger logger = LoggerFactory.getLogger(FindMessageAsync.class); + public static final String OK = "OK"; + public static final String KO = "KO"; + + public String execute(PubSubData pubSubData) throws JsonProcessingException { + + long timeoutSeconds = 30; + if (Objects.nonNull(pubSubData.timeoutConsumeMessageSeconds)) { + timeoutSeconds = pubSubData.timeoutConsumeMessageSeconds; + } + + ProjectSubscriptionName subscriptionName = ProjectSubscriptionName.of(pubSubData.projectId, pubSubData.subscriptionId); + Subscriber subscriber = null; + + try { + CompletableFuture messageResult = new CompletableFuture<>(); + + Set ignoreFieldsCopy = new HashSet<>(); + if (pubSubData.ignoreMessageFields != null && !pubSubData.ignoreMessageFields.isEmpty()) { + ignoreFieldsCopy.addAll(pubSubData.ignoreMessageFields); + } + + JsonNode expectedMessageTree = Utils.mapperMessageToJsonNode(pubSubData.expectedMessage, ignoreFieldsCopy); + + subscriber = Subscriber.newBuilder(subscriptionName, + (PubsubMessage message, AckReplyConsumer consumer) -> { + consumer.ack(); + String stringMessage = message.getData().toStringUtf8(); + JsonNode messageTree = Utils.mapperMessageToJsonNode(stringMessage, ignoreFieldsCopy); + + logger.info("Received Message - OrderingKey: {}, Data: {}, Attributes: {}", + message.getOrderingKey(), + messageTree.toPrettyString(), + message.getAttributesMap()); + + if (Utils.compareJsonNodes(expectedMessageTree, messageTree) + && Utils.isValidOrdering(pubSubData, message) + && Utils.containsAttributes(pubSubData, message)) { + messageResult.complete(OK); + } + }) + .setCredentialsProvider(NoCredentialsProvider.create()) + .setChannelProvider(pubSubData.channelProvider).build(); + subscriber.startAsync().awaitRunning(); + + Timer timer = new Timer(); + timer.schedule(new TimerTask() { + @Override + public void run() { + messageResult.obtrudeException(new RuntimeException("message not found after timeout")); + } + }, timeoutSeconds * 1000); + + return messageResult.get(); + } catch (Exception e) { + logger.error("error searching message", e); + return KO; + } finally { + if (subscriber != null) { + subscriber.stopAsync().awaitTerminated(); + } + } + } +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/FindMessageDebug.java b/test-launcher/src/test/java/launcher/pubsub/operations/FindMessageDebug.java new file mode 100644 index 0000000..87f971d --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/FindMessageDebug.java @@ -0,0 +1,82 @@ +package launcher.pubsub.operations; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.pubsub.v1.AcknowledgeRequest; +import com.google.pubsub.v1.ProjectSubscriptionName; +import com.google.pubsub.v1.PullRequest; +import com.google.pubsub.v1.PullResponse; +import com.google.pubsub.v1.ReceivedMessage; +import launcher.pubsub.Utils; +import launcher.pubsub.PubSub.PubSubData; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FindMessageDebug implements PubSubOperation { + + private final Logger logger = LoggerFactory.getLogger(FindMessageDebug.class); + + public String execute(PubSubData pubSubData) throws JsonProcessingException { + + String log = ""; + String responseMessage = null; + String subscriptionName = ProjectSubscriptionName.format(pubSubData.projectId, pubSubData.subscriptionId); + int numberMessages = Optional.ofNullable(pubSubData.maxRequestedMessages).orElse(MAX_REQUESTED_MESSAGES); + PullRequest pullRequest = PullRequest.newBuilder().setMaxMessages(numberMessages) + .setSubscription(subscriptionName) + .build(); + + // Use pullCallable().futureCall to asynchronously perform this operation. + PullResponse pullResponse = pubSubData.subscriptionClient.pullCallable().call(pullRequest); + List messages = pullResponse.getReceivedMessagesList(); + + if (messages.isEmpty()) { + throw new RuntimeException("No message to consume"); + } + + JsonNode expectedMessageTree = Utils.mapperMessageToJsonNode(pubSubData.expectedMessage, pubSubData); + log = log + expectedMessageTree.toString() + "\n"; + + boolean notFoundMessage = true; + + log = log + "****************************************************************" + "\n"; + for (ReceivedMessage message : messages) { + + String stringMessage = message.getMessage().getData().toStringUtf8(); + + JsonNode messageTree = Utils.mapperMessageToJsonNode(stringMessage, pubSubData); + log = log + "New message was found! \n"; + log = log + messageTree.toString() + "\n"; + log = log + "************************" + expectedMessageTree.equals(messageTree) + "****************************************" + "\n"; + // Handle received message + if (notFoundMessage && expectedMessageTree.equals(messageTree) + && Utils.isValidOrdering(pubSubData, message) + && Utils.containsAttributes(pubSubData, message)) { + notFoundMessage = false; + // Acknowledge received messages. + AcknowledgeRequest acknowledgeRequest = AcknowledgeRequest.newBuilder() + .setSubscription(subscriptionName) + .addAckIds(message.getAckId()).build(); + + // Use acknowledgeCallable().futureCall to asynchronously perform this + // operation. + pubSubData.subscriptionClient.acknowledgeCallable().call(acknowledgeRequest); + + logger.info("message: {} consumed correctly", message); + responseMessage = stringMessage; + } else { + // Nack message + pubSubData.subscriptionClient.modifyAckDeadline(subscriptionName, Arrays.asList(message.getAckId()), + 0); + } + } + + return responseMessage; + } + +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/PubSubOperation.java b/test-launcher/src/test/java/launcher/pubsub/operations/PubSubOperation.java new file mode 100644 index 0000000..d3220aa --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/PubSubOperation.java @@ -0,0 +1,10 @@ +package launcher.pubsub.operations; + +import launcher.pubsub.PubSub.PubSubData; + +public interface PubSubOperation { + + Integer MAX_REQUESTED_MESSAGES = 100; + + String execute(PubSubData pubSubData) throws Exception; +} diff --git a/test-launcher/src/test/java/launcher/pubsub/operations/PublishMessage.java b/test-launcher/src/test/java/launcher/pubsub/operations/PublishMessage.java new file mode 100644 index 0000000..b410cd9 --- /dev/null +++ b/test-launcher/src/test/java/launcher/pubsub/operations/PublishMessage.java @@ -0,0 +1,89 @@ +package launcher.pubsub.operations; + +import com.google.api.client.util.Strings; +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutureCallback; +import com.google.api.core.ApiFutures; +import com.google.api.gax.rpc.ApiException; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.ByteString; +import com.google.pubsub.v1.PubsubMessage; +import com.google.pubsub.v1.PubsubMessage.Builder; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import launcher.pubsub.PubSub.PubSubData; + +import java.io.IOException; +import java.net.URI; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PublishMessage implements PubSubOperation { + private final Logger logger = LoggerFactory.getLogger(PublishMessage.class); + + @SuppressWarnings("null") + public String execute(PubSubData pubSubData) throws IOException { + try { + CloudEvent event = CloudEventBuilder.v1().withId(UUID.randomUUID().toString()) + .withType(pubSubData.ceType).withSource(URI.create(pubSubData.ceSource)) + .withTime(OffsetDateTime.now()).build(); + + ByteString data = ByteString.copyFromUtf8(pubSubData.message); + Builder pubsubMessageBuilder = PubsubMessage.newBuilder(); + + event.getAttributeNames().forEach(attribute -> pubsubMessageBuilder.putAttributes("ce-" + attribute, + Objects.requireNonNull(event.getAttribute(attribute)).toString())); + + if (pubSubData.attributes != null && !pubSubData.attributes.isEmpty()) { + pubSubData.attributes.forEach(pubsubMessageBuilder::putAttributes); + } + + pubsubMessageBuilder.putAttributes("Content-Type", "application/json"); + + if (!Strings.isNullOrEmpty(pubSubData.orderingKey)) { + pubsubMessageBuilder.setOrderingKey(pubSubData.orderingKey); + } + + PubsubMessage pubsubMessage = pubsubMessageBuilder.setData(data).build(); + + ApiFuture future = pubSubData.publisher.publish(pubsubMessage); + + // Add an asynchronous callback to handle success / failure + ApiFutures.addCallback(future, new ApiFutureCallback() { + + @Override + public void onFailure(Throwable throwable) { + if (throwable instanceof ApiException) { + ApiException apiException = ((ApiException) throwable); + // details on the API exception + logger.error("onFailure. CODE: {}. Retryable: {}", apiException.getStatusCode().getCode(), apiException.isRetryable()); + } + logger.error("Error publishing message : {}.", pubSubData.message, throwable); + } + + @Override + public void onSuccess(String messageId) { + // Once published, returns server-assigned message ids (unique within the topic) + logger.info("Published message ID {}. Message: {} with orderingKey {}", messageId, data.toStringUtf8(), pubSubData.orderingKey); + } + }, MoreExecutors.directExecutor()); + + } finally { + if (pubSubData.publisher != null) { + // When finished with the publisher, shutdown to free up resources. + pubSubData.publisher.shutdown(); + try { + pubSubData.publisher.awaitTermination(1, TimeUnit.MINUTES); + } catch (InterruptedException e) { + logger.error("Exception shutting down pubsub ", e); + } + } + } + return "OK"; + } +} diff --git a/test-launcher/src/test/java/launcher/redis/RedisClient.java b/test-launcher/src/test/java/launcher/redis/RedisClient.java new file mode 100644 index 0000000..3e406e8 --- /dev/null +++ b/test-launcher/src/test/java/launcher/redis/RedisClient.java @@ -0,0 +1,69 @@ +package launcher.redis; + +import java.util.Map; + +import redis.clients.jedis.Jedis; + +/** + * Redis helper for seeding and asserting state against a running Redis. + * Exposed to Karate via Java.type. Each call uses a short-lived connection. + */ +public class RedisClient { + + private final String host; + private final int port; + private final String password; + + /** + * @param config {@code host} (default localhost), {@code port} (default 6379), optional {@code password}. + */ + public RedisClient(final Map config) { + this.host = config.getOrDefault("host", "localhost").toString(); + this.port = Integer.parseInt(config.getOrDefault("port", "6379").toString()); + final Object pwd = config.get("password"); + this.password = pwd != null ? pwd.toString() : null; + } + + private Jedis open() { + final Jedis jedis = new Jedis(host, port); + if (password != null && !password.isEmpty()) { + jedis.auth(password); + } + return jedis; + } + + /** Set a string value. */ + public String set(final String key, final String value) { + try (Jedis jedis = open()) { + return jedis.set(key, value); + } + } + + /** Get a string value (null if missing). */ + public String get(final String key) { + try (Jedis jedis = open()) { + return jedis.get(key); + } + } + + /** Whether a key exists. */ + public Boolean exists(final String key) { + try (Jedis jedis = open()) { + return jedis.exists(key); + } + } + + /** Delete a key, returning the number of keys removed. */ + public Long del(final String key) { + try (Jedis jedis = open()) { + return jedis.del(key); + } + } + + /** Set a key with a time-to-live in seconds. */ + public String setEx(final String key, final long seconds, final String value) { + try (Jedis jedis = open()) { + return jedis.setex(key, seconds, value); + } + } +} diff --git a/test-launcher/src/test/java/launcher/s3/S3Client.java b/test-launcher/src/test/java/launcher/s3/S3Client.java new file mode 100644 index 0000000..021bd9b --- /dev/null +++ b/test-launcher/src/test/java/launcher/s3/S3Client.java @@ -0,0 +1,87 @@ +package launcher.s3; + +import java.net.URI; +import java.util.Map; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +/** + * S3-compatible object storage helper (AWS S3 or MinIO via endpoint override), + * for seeding and asserting objects. Exposed to Karate via Java.type. + */ +public class S3Client { + + private final software.amazon.awssdk.services.s3.S3Client s3; + + /** + * @param config {@code endpoint} (optional, e.g. http://localhost:9000 for MinIO), + * {@code region} (default us-east-1), {@code accessKey}, {@code secretKey}. + * Path-style access is enabled for MinIO compatibility. + */ + public S3Client(final Map config) { + final String region = config.getOrDefault("region", "us-east-1").toString(); + final String accessKey = config.getOrDefault("accessKey", "test").toString(); + final String secretKey = config.getOrDefault("secretKey", "test").toString(); + + final var builder = software.amazon.awssdk.services.s3.S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()); + + final Object endpoint = config.get("endpoint"); + if (endpoint != null) { + builder.endpointOverride(URI.create(endpoint.toString())); + } + this.s3 = builder.build(); + } + + /** Upload an object from a string body. */ + public void putObject(final String bucket, final String key, final String content) { + s3.putObject(PutObjectRequest.builder().bucket(bucket).key(key).build(), + RequestBody.fromString(content)); + } + + /** Download an object as bytes (null if missing). */ + public byte[] getObject(final String bucket, final String key) { + try { + final ResponseBytes response = s3.getObjectAsBytes( + GetObjectRequest.builder().bucket(bucket).key(key).build()); + return response.asByteArray(); + } catch (NoSuchKeyException e) { + return null; + } + } + + /** Download an object as a UTF-8 string (null if missing). */ + public String getObjectAsString(final String bucket, final String key) { + final byte[] bytes = getObject(bucket, key); + return bytes != null ? new String(bytes) : null; + } + + /** Whether an object exists. */ + public Boolean objectExists(final String bucket, final String key) { + try { + s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + /** Delete an object. */ + public void deleteObject(final String bucket, final String key) { + s3.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build()); + } +} diff --git a/test-launcher/src/test/java/launcher/util/Faker.java b/test-launcher/src/test/java/launcher/util/Faker.java new file mode 100644 index 0000000..673fa50 --- /dev/null +++ b/test-launcher/src/test/java/launcher/util/Faker.java @@ -0,0 +1,42 @@ +package launcher.util; + +/** + * Random test-data generator (wraps datafaker). Exposed to Karate via Java.type. + */ +public class Faker { + + private final net.datafaker.Faker faker = new net.datafaker.Faker(); + + public String fullName() { + return faker.name().fullName(); + } + + public String firstName() { + return faker.name().firstName(); + } + + public String lastName() { + return faker.name().lastName(); + } + + public String email() { + return faker.internet().emailAddress(); + } + + public String uuid() { + return faker.internet().uuid(); + } + + public String word() { + return faker.lorem().word(); + } + + public long numberBetween(final long min, final long max) { + return faker.number().numberBetween(min, max); + } + + /** Evaluate a datafaker expression, e.g. {@code "#{name.fullName}"}. */ + public String expression(final String expr) { + return faker.expression(expr); + } +} diff --git a/test-launcher/src/test/java/launcher/util/FakerTest.java b/test-launcher/src/test/java/launcher/util/FakerTest.java new file mode 100644 index 0000000..3ac420d --- /dev/null +++ b/test-launcher/src/test/java/launcher/util/FakerTest.java @@ -0,0 +1,34 @@ +package launcher.util; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class FakerTest { + + private final Faker faker = new Faker(); + + @Test + void uuidIsParseable() { + UUID.fromString(faker.uuid()); // throws if invalid + } + + @Test + void emailLooksLikeEmail() { + assertTrue(faker.email().contains("@")); + } + + @Test + void numberBetweenIsInRange() { + final long n = faker.numberBetween(5, 10); + assertTrue(n >= 5 && n < 10); + } + + @Test + void fullNameNotNull() { + assertNotNull(faker.fullName()); + } +} diff --git a/test-launcher/src/test/java/launcher/util/JsonSchema.java b/test-launcher/src/test/java/launcher/util/JsonSchema.java new file mode 100644 index 0000000..f509d38 --- /dev/null +++ b/test-launcher/src/test/java/launcher/util/JsonSchema.java @@ -0,0 +1,42 @@ +package launcher.util; + +import java.util.Set; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; + +/** + * JSON Schema (Draft 2020-12) validation helper for asserting response shapes. + * Exposed to Karate via Java.type. + */ +public class JsonSchema { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + + /** Whether the instance JSON validates against the schema JSON. */ + public boolean validate(final String schemaJson, final String instanceJson) { + return errors(schemaJson, instanceJson).isEmpty(); + } + + /** Validation error messages (empty when valid), joined by "; ". */ + public String validationErrors(final String schemaJson, final String instanceJson) { + return errors(schemaJson, instanceJson).stream() + .map(ValidationMessage::getMessage) + .collect(Collectors.joining("; ")); + } + + private Set errors(final String schemaJson, final String instanceJson) { + try { + final com.networknt.schema.JsonSchema schema = factory.getSchema(schemaJson); + final JsonNode instance = MAPPER.readTree(instanceJson); + return schema.validate(instance); + } catch (Exception e) { + throw new RuntimeException("Error validating JSON against schema", e); + } + } +} diff --git a/test-launcher/src/test/java/launcher/util/JsonSchemaTest.java b/test-launcher/src/test/java/launcher/util/JsonSchemaTest.java new file mode 100644 index 0000000..ed2ca8b --- /dev/null +++ b/test-launcher/src/test/java/launcher/util/JsonSchemaTest.java @@ -0,0 +1,37 @@ +package launcher.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class JsonSchemaTest { + + private final JsonSchema jsonSchema = new JsonSchema(); + + private static final String SCHEMA = "{" + + "\"type\":\"object\"," + + "\"required\":[\"name\"]," + + "\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"integer\"}}" + + "}"; + + @Test + void validInstancePasses() { + assertTrue(jsonSchema.validate(SCHEMA, "{\"name\":\"ada\",\"age\":36}")); + } + + @Test + void missingRequiredFails() { + assertFalse(jsonSchema.validate(SCHEMA, "{\"age\":36}")); + } + + @Test + void wrongTypeFails() { + assertFalse(jsonSchema.validate(SCHEMA, "{\"name\":\"ada\",\"age\":\"old\"}")); + } + + @Test + void errorsReportedForInvalid() { + assertFalse(jsonSchema.validationErrors(SCHEMA, "{\"age\":36}").isEmpty()); + } +} diff --git a/test-launcher/src/test/java/launcher/util/Jwt.java b/test-launcher/src/test/java/launcher/util/Jwt.java new file mode 100644 index 0000000..6f8b9ba --- /dev/null +++ b/test-launcher/src/test/java/launcher/util/Jwt.java @@ -0,0 +1,41 @@ +package launcher.util; + +import java.util.Base64; +import java.util.Map; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; + +/** + * JWT helper for generating, verifying and inspecting HS256 tokens in tests. + * Exposed to Karate via Java.type. + */ +public class Jwt { + + /** Create an HS256-signed token from a claims map. */ + public String generateHs256(final String secret, final Map claims) { + return JWT.create().withPayload(claims).sign(Algorithm.HMAC256(secret)); + } + + /** Verify an HS256 token signature against a secret. */ + public boolean verifyHs256(final String token, final String secret) { + try { + JWT.require(Algorithm.HMAC256(secret)).build().verify(token); + return true; + } catch (JWTVerificationException e) { + return false; + } + } + + /** Read a string claim from a token (no signature verification). */ + public String getClaim(final String token, final String name) { + return JWT.decode(token).getClaim(name).asString(); + } + + /** Return the decoded JSON payload of a token (no signature verification). */ + public String decodePayload(final String token) { + final String payload = JWT.decode(token).getPayload(); + return new String(Base64.getUrlDecoder().decode(payload)); + } +} diff --git a/test-launcher/src/test/java/launcher/util/JwtTest.java b/test-launcher/src/test/java/launcher/util/JwtTest.java new file mode 100644 index 0000000..bf5303a --- /dev/null +++ b/test-launcher/src/test/java/launcher/util/JwtTest.java @@ -0,0 +1,38 @@ +package launcher.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class JwtTest { + + private final Jwt jwt = new Jwt(); + + @Test + void generateAndVerifyRoundtrip() { + final String token = jwt.generateHs256("secret", Map.of("sub", "user-1", "role", "admin")); + assertTrue(jwt.verifyHs256(token, "secret")); + } + + @Test + void verifyFailsWithWrongSecret() { + final String token = jwt.generateHs256("secret", Map.of("sub", "user-1")); + assertFalse(jwt.verifyHs256(token, "other-secret")); + } + + @Test + void getClaimReturnsValue() { + final String token = jwt.generateHs256("secret", Map.of("role", "admin")); + assertEquals("admin", jwt.getClaim(token, "role")); + } + + @Test + void decodePayloadContainsClaim() { + final String token = jwt.generateHs256("secret", Map.of("sub", "user-1")); + assertTrue(jwt.decodePayload(token).contains("user-1")); + } +} diff --git a/test-launcher/src/test/java/logback-test.xml b/test-launcher/src/test/java/logback-test.xml new file mode 100644 index 0000000..ff569c1 --- /dev/null +++ b/test-launcher/src/test/java/logback-test.xml @@ -0,0 +1,29 @@ + + + + + + WARN + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + target/karate.log + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/test-launcher/src/test/java/logback-test_debug.xml b/test-launcher/src/test/java/logback-test_debug.xml new file mode 100644 index 0000000..8a2eb47 --- /dev/null +++ b/test-launcher/src/test/java/logback-test_debug.xml @@ -0,0 +1,26 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + target/karate.log + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/test-launcher/src/test/java/utils/DateUtilsTest.java b/test-launcher/src/test/java/utils/DateUtilsTest.java new file mode 100644 index 0000000..e0ccb5a --- /dev/null +++ b/test-launcher/src/test/java/utils/DateUtilsTest.java @@ -0,0 +1,53 @@ +package utils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class DateUtilsTest { + + private final DateUtils dateUtils = new DateUtils(); + + @Test + void compareDatesSameDateSameOffset() { + assertTrue(dateUtils.compareDates("2022-05-26T07:00:21Z", "2022-05-26T07:00:21Z")); + } + + @Test + void compareDatesDifferentOffset() { + assertTrue(dateUtils.compareDates("2022-05-26T07:00:21Z", "2022-05-26T08:00:21+01:00")); + } + + @Test + void dateInCurrentTZ() { + final String date = "2022-09-22T19:45:06Z"; + assertTrue(dateUtils.compareDates(date, dateUtils.dateInCurrentTZ(date))); + } + + @Test + void compareDatesWithThresholdSameOffset() { + assertTrue(dateUtils.compareDates("2022-05-26T07:00:21Z", "2022-05-26T07:00:25Z", 5L)); + } + + @Test + void compareDatesWithThresholdDifferentOffset() { + assertTrue(dateUtils.compareDates("2022-05-26T07:00:21Z", "2022-05-26T08:00:25+01:00", 5L)); + } + + @Test + void isAfterDifferentOffset() { + final String date1 = "2022-05-26T07:00:21Z"; + final String date2 = "2022-05-26T08:00:25+01:00"; + assertTrue(dateUtils.isAfter(date2, date1)); + assertFalse(dateUtils.isAfter(date1, date2)); + } + + @Test + void isBeforeDifferentOffset() { + final String date1 = "2022-05-26T07:00:21Z"; + final String date2 = "2022-05-26T08:00:25+01:00"; + assertFalse(dateUtils.isBefore(date2, date1)); + assertTrue(dateUtils.isBefore(date1, date2)); + } +} diff --git a/test/component/mocks-data/mountebank/hello-imposter.json b/test/component/mocks-data/mountebank/hello-imposter.json new file mode 100644 index 0000000..8b603f0 --- /dev/null +++ b/test/component/mocks-data/mountebank/hello-imposter.json @@ -0,0 +1,18 @@ +{ + "port": 4545, + "protocol": "http", + "name": "hello", + "stubs": [ + { + "responses": [ + { + "is": { + "statusCode": 200, + "headers": { "Content-Type": "application/json" }, + "body": "{\"message\":\"hello from mountebank\"}" + } + } + ] + } + ] +} diff --git a/test/fixtures/config/base-for-overrides.yml b/test/fixtures/config/base-for-overrides.yml new file mode 100644 index 0000000..7ff0249 --- /dev/null +++ b/test/fixtures/config/base-for-overrides.yml @@ -0,0 +1,10 @@ +version: v1 +app-technology: golang +app-config: + binary-name: myapp + port: 8080 +test-launcher: test-launcher-back +observability: + log-level: info +orchestration: + parallel-mocks: true diff --git a/test/fixtures/config/defaults-only.yml b/test/fixtures/config/defaults-only.yml new file mode 100644 index 0000000..65593a1 --- /dev/null +++ b/test/fixtures/config/defaults-only.yml @@ -0,0 +1,3 @@ +version: v1 +app-technology: golang +test-launcher: test-launcher-back diff --git a/test/fixtures/config/full.yml b/test/fixtures/config/full.yml new file mode 100644 index 0000000..75594d6 --- /dev/null +++ b/test/fixtures/config/full.yml @@ -0,0 +1,35 @@ +version: v1 +app-technology: nodejs +app-config: + binary-name: my-service + binary-path: ./dist + docker-image: myapp:latest + port: 3000 + environment: + NODE_ENV: test + LOG_LEVEL: debug +test-launcher: test-launcher-front +test-config: + tags: "@smoke" + features-path: ./features + reports-path: ./reports + parallel: true +third-party: + mocks: + - couchbase + - postgresql + mock-config: + couchbase: + bucket: test-bucket +orchestration: + parallel-mocks: false + startup-timeout: 60s + health-check-interval: 5s + health-check-retries: 30 + cleanup-on-failure: false + preserve-logs: false +observability: + structured-logs: true + log-level: debug + metrics-enabled: false + report-format: json diff --git a/test/fixtures/config/invalid-yaml.yml b/test/fixtures/config/invalid-yaml.yml new file mode 100644 index 0000000..a11f46d --- /dev/null +++ b/test/fixtures/config/invalid-yaml.yml @@ -0,0 +1,2 @@ +version: v1 + invalid: [unclosed diff --git a/test/fixtures/config/minimal.yml b/test/fixtures/config/minimal.yml new file mode 100644 index 0000000..ce3af4e --- /dev/null +++ b/test/fixtures/config/minimal.yml @@ -0,0 +1,6 @@ +version: v1 +app-technology: golang +app-config: + binary-name: test-app + port: 8080 +test-launcher: test-launcher-back diff --git a/test/fixtures/config/with-env-vars.yml b/test/fixtures/config/with-env-vars.yml new file mode 100644 index 0000000..b9cbe95 --- /dev/null +++ b/test/fixtures/config/with-env-vars.yml @@ -0,0 +1,8 @@ +version: v1 +app-technology: golang +app-config: + binary-name: ${APP_NAME} + port: 8080 + environment: + DATABASE_URL: ${DB_HOST}:${DB_PORT} +test-launcher: test-launcher-back