diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 914ac38f..09c838d1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -43,7 +43,7 @@ This is the Modular Go framework - a structured way to create modular applicatio ## Development Workflow ### Local Development Setup -1. Clone the repository: `git clone https://github.com/CrisisTextLine/modular.git` +1. Clone the repository: `git clone https://github.com/GoCodeAlone/modular.git` 2. Install Go 1.23.0 or later (toolchain uses 1.24.2) 3. Install golangci-lint: `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` 4. Run tests to verify setup: `go test ./... -v` @@ -153,7 +153,7 @@ Working example applications: ### CLI Tool (`modcli`) - Generate new modules: `modcli generate module --name MyModule` - Generate configurations: `modcli generate config --name MyConfig` -- Install with: `go install github.com/CrisisTextLine/modular/cmd/modcli@latest` +- Install with: `go install github.com/GoCodeAlone/modular/cmd/modcli@latest` ### Debugging Tools - Debug module interfaces: `modular.DebugModuleInterfaces(app, "module-name")` diff --git a/.github/workflows/auto-bump-modules.yml b/.github/workflows/auto-bump-modules.yml index 4dd2b470..d1b87844 100644 --- a/.github/workflows/auto-bump-modules.yml +++ b/.github/workflows/auto-bump-modules.yml @@ -43,7 +43,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Determine version @@ -63,12 +63,12 @@ jobs: [ -f "$mod" ] || continue dir=$(dirname "$mod") # If the require line exists with a version different from CORE, update via go mod edit (portable, avoids sed incompat) - if grep -q "github.com/CrisisTextLine/modular v" "$mod" && ! grep -q "github.com/CrisisTextLine/modular ${CORE}" "$mod"; then - (cd "$dir" && go mod edit -require=github.com/CrisisTextLine/modular@${CORE}) + if grep -q "github.com/GoCodeAlone/modular v" "$mod" && ! grep -q "github.com/GoCodeAlone/modular ${CORE}" "$mod"; then + (cd "$dir" && go mod edit -require=github.com/GoCodeAlone/modular@${CORE}) UPDATED=1 fi # Drop any replace directive pointing to local modular path to avoid accidental pinning - (cd "$dir" && go mod edit -dropreplace=github.com/CrisisTextLine/modular 2>/dev/null || true) + (cd "$dir" && go mod edit -dropreplace=github.com/GoCodeAlone/modular 2>/dev/null || true) done if [ "$UPDATED" = 0 ]; then echo "No module files needed updating" @@ -114,10 +114,10 @@ jobs: run: | set -euo pipefail CORE=${{ steps.ver.outputs.core_version }} - OLD=$(git grep -h -o 'github.com/CrisisTextLine/modular v[0-9]\+\.[0-9]\+\.[0-9]\+' -- '*.md' | grep -v $CORE | head -n1 | awk '{print $1}' || true) + OLD=$(git grep -h -o 'github.com/GoCodeAlone/modular v[0-9]\+\.[0-9]\+\.[0-9]\+' -- '*.md' | grep -v $CORE | head -n1 | awk '{print $1}' || true) # Replace any explicit old version with current in markdown examples if [ -n "$OLD" ]; then - find . -name '*.md' -print0 | xargs -0 sed -i "" -E "s#github.com/CrisisTextLine/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/CrisisTextLine/modular ${CORE}#g" || find . -name '*.md' -print0 | xargs -0 sed -i -E "s#github.com/CrisisTextLine/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/CrisisTextLine/modular ${CORE}#g" + find . -name '*.md' -print0 | xargs -0 sed -i "" -E "s#github.com/GoCodeAlone/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/GoCodeAlone/modular ${CORE}#g" || find . -name '*.md' -print0 | xargs -0 sed -i -E "s#github.com/GoCodeAlone/modular v[0-9]+\.[0-9]+\.[0-9]+#github.com/GoCodeAlone/modular ${CORE}#g" fi - name: Create PR diff --git a/.github/workflows/bdd-matrix.yml b/.github/workflows/bdd-matrix.yml index a4df8318..16a0d109 100644 --- a/.github/workflows/bdd-matrix.yml +++ b/.github/workflows/bdd-matrix.yml @@ -21,7 +21,7 @@ on: workflow_dispatch: env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: # Discover modules (reused for matrix) @@ -67,7 +67,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: core-bdd-coverage.txt flags: core-bdd - name: Persist core BDD coverage artifact @@ -127,7 +127,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: modules/${{ matrix.module }}/bdd-${{ matrix.module }}-coverage.txt flags: bdd-${{ matrix.module }} - name: Persist module BDD coverage artifact @@ -203,7 +203,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: merged-bdd-coverage.txt flags: merged-bdd - name: Persist merged BDD coverage artifact diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3512332..d130ea2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: branches: [ main ] env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: test: @@ -43,7 +43,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: coverage.txt flags: unit - name: Upload unit coverage artifact @@ -94,7 +94,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular directory: cmd/modcli/ files: cli-coverage.txt flags: cli @@ -192,7 +192,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular files: total-coverage.txt flags: total fail_ci_if_error: true diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 389b9532..9f27003b 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -22,7 +22,7 @@ on: default: 'patch' env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' permissions: contents: write @@ -168,7 +168,7 @@ jobs: - name: Build run: | cd cmd/modcli - go build -v -ldflags "-X github.com/CrisisTextLine/modular/cmd/modcli/cmd.Version=${{ needs.prepare.outputs.version }} -X github.com/CrisisTextLine/modular/cmd/modcli/cmd.Commit=${{ github.sha }} -X github.com/CrisisTextLine/modular/cmd/modcli/cmd.Date=$(date +'%Y-%m-%d')" -o ${{ matrix.artifact_name }} + go build -v -ldflags "-X github.com/GoCodeAlone/modular/cmd/modcli/cmd.Version=${{ needs.prepare.outputs.version }} -X github.com/GoCodeAlone/modular/cmd/modcli/cmd.Commit=${{ github.sha }} -X github.com/GoCodeAlone/modular/cmd/modcli/cmd.Date=$(date +'%Y-%m-%d')" -o ${{ matrix.artifact_name }} shell: bash - name: Upload artifact @@ -252,7 +252,7 @@ jobs: - name: Announce to Go proxy run: | VERSION="${{ needs.prepare.outputs.version }}" - MODULE_NAME="github.com/CrisisTextLine/modular/cmd/modcli" + MODULE_NAME="github.com/GoCodeAlone/modular/cmd/modcli" GOPROXY=proxy.golang.org go list -m ${MODULE_NAME}@${VERSION} diff --git a/.github/workflows/contract-check.yml b/.github/workflows/contract-check.yml index ba7eae7b..47d394da 100644 --- a/.github/workflows/contract-check.yml +++ b/.github/workflows/contract-check.yml @@ -16,7 +16,7 @@ permissions: actions: read env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: contract-check: @@ -59,7 +59,7 @@ jobs: - name: Checkout PR branch run: | - git checkout ${{ github.head_ref }} + git checkout ${{ github.event.pull_request.head.sha }} - name: Extract contracts from PR branch run: | diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 7593d425..5132f7b8 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' cache-dependency-path: go.sum # Install Go dependencies and development tools diff --git a/.github/workflows/examples-ci.yml b/.github/workflows/examples-ci.yml index 0b20152e..5e908ba3 100644 --- a/.github/workflows/examples-ci.yml +++ b/.github/workflows/examples-ci.yml @@ -12,7 +12,7 @@ on: workflow_dispatch: env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: validate-examples: @@ -448,10 +448,10 @@ jobs: echo "🔍 Verifying go.mod configuration for ${{ matrix.example }}..." - # Check that replace directives point to correct paths - if ! grep -q "replace.*=> ../../" go.mod; then + # Check that replace directives point to correct local paths + if ! grep -q "replace.*=> \.\./\.\." go.mod; then echo "❌ Missing or incorrect replace directive in ${{ matrix.example }}/go.mod" - echo "Expected: replace github.com/CrisisTextLine/modular => ../../" + echo "Expected: replace github.com/GoCodeAlone/modular => ../.." cat go.mod exit 1 fi diff --git a/.github/workflows/module-release.yml b/.github/workflows/module-release.yml index 7e4dd14c..54300f58 100644 --- a/.github/workflows/module-release.yml +++ b/.github/workflows/module-release.yml @@ -12,8 +12,10 @@ on: - auth - cache - chimux + - configwatcher - database - eventbus + - eventlogger - httpclient - httpserver - jsonschema @@ -138,7 +140,7 @@ jobs: if: steps.skipcheck.outputs.changed == 'true' uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli @@ -482,9 +484,9 @@ jobs: # Construct correct module path with version suffix for v2+ if [ "$MAJOR_VERSION" -ge 2 ]; then - MODULE_NAME="github.com/CrisisTextLine/modular/modules/${MODULE}/v${MAJOR_VERSION}" + MODULE_NAME="github.com/GoCodeAlone/modular/modules/${MODULE}/v${MAJOR_VERSION}" else - MODULE_NAME="github.com/CrisisTextLine/modular/modules/${MODULE}" + MODULE_NAME="github.com/GoCodeAlone/modular/modules/${MODULE}" fi echo "Announcing ${MODULE_NAME}@${VERSION} to Go proxy..." diff --git a/.github/workflows/modules-ci.yml b/.github/workflows/modules-ci.yml index 87ea93bd..c6b8b942 100644 --- a/.github/workflows/modules-ci.yml +++ b/.github/workflows/modules-ci.yml @@ -20,7 +20,7 @@ on: workflow_dispatch: env: - GO_VERSION: '^1.25' + GO_VERSION: '^1.26' jobs: # This job identifies which modules have been modified @@ -137,7 +137,7 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.0 pinned with: token: ${{ secrets.CODECOV_TOKEN }} - slug: CrisisTextLine/modular + slug: GoCodeAlone/modular directory: modules/${{ matrix.module }}/ files: ${{ matrix.module }}-coverage.txt flags: ${{ matrix.module }} diff --git a/.github/workflows/release-all.yml b/.github/workflows/release-all.yml index de6aa896..dcbe7d1a 100644 --- a/.github/workflows/release-all.yml +++ b/.github/workflows/release-all.yml @@ -42,7 +42,7 @@ jobs: echo "Latest core tag: $LATEST_TAG" HAS_CHANGES=false if [ -z "$LATEST_TAG" ]; then - FILE=$(find . -maxdepth 1 -type f \( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' \) | head -1 || true) + FILE=$(find . -maxdepth 1 -type f \( -name '*.go' -o -name 'go.mod' \) | head -1 || true) if [ -n "$FILE" ]; then HAS_CHANGES=true; fi else CHANGED=$(git diff --name-only ${LATEST_TAG}..HEAD | grep -v '^modules/' || true) @@ -53,8 +53,11 @@ jobs: [[ $f == *.md ]] && continue [[ $f == .github/* ]] && continue [[ $f == examples/* ]] && continue - # Accept .go plus root go.mod/go.sum (allow optional leading ./) - if [[ $f == *.go ]] || [[ $f == go.mod ]] || [[ $f == go.sum ]] || [[ $f == ./go.mod ]] || [[ $f == ./go.sum ]]; then + [[ $f == cmd/* ]] && continue + # Accept .go plus root go.mod (NOT go.sum — it's a lockfile that + # changes whenever auto-bump runs go mod tidy and should not + # trigger a new core release by itself). + if [[ $f == *.go ]] || [[ $f == go.mod ]] || [[ $f == ./go.mod ]]; then RELEVANT+="$f " fi done <<< "$CHANGED" @@ -172,7 +175,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli run: | @@ -275,7 +278,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli run: | @@ -333,9 +336,9 @@ jobs: # Construct correct module path with version suffix for v2+ if [ "$MAJOR_VERSION" -ge 2 ]; then - MODULE_NAME="github.com/CrisisTextLine/modular/v${MAJOR_VERSION}" + MODULE_NAME="github.com/GoCodeAlone/modular/v${MAJOR_VERSION}" else - MODULE_NAME="github.com/CrisisTextLine/modular" + MODULE_NAME="github.com/GoCodeAlone/modular" fi GOPROXY=proxy.golang.org go list -m ${MODULE_NAME}@${CURR} @@ -354,7 +357,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli run: | @@ -377,9 +380,9 @@ jobs: MAJOR_VERSION="${VER#v}" MAJOR_VERSION="${MAJOR_VERSION%%.*}" if [ "$MAJOR_VERSION" -ge 2 ]; then - MOD_PATH="github.com/CrisisTextLine/modular/modules/${M}/v${MAJOR_VERSION}" + MOD_PATH="github.com/GoCodeAlone/modular/modules/${M}/v${MAJOR_VERSION}" else - MOD_PATH="github.com/CrisisTextLine/modular/modules/${M}" + MOD_PATH="github.com/GoCodeAlone/modular/modules/${M}" fi if gh release view "$TAG" >/dev/null 2>&1; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7064b6fd..45b21ed5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: if: steps.detect.outputs.core_changed == 'true' uses: actions/setup-go@v6 with: - go-version: '^1.25' + go-version: '^1.26' check-latest: true - name: Build modcli @@ -424,9 +424,9 @@ jobs: # Construct correct module path with version suffix for v2+ if [ "$MAJOR_VERSION" -ge 2 ]; then - MODULE_NAME="github.com/CrisisTextLine/modular/v${MAJOR_VERSION}" + MODULE_NAME="github.com/GoCodeAlone/modular/v${MAJOR_VERSION}" else - MODULE_NAME="github.com/CrisisTextLine/modular" + MODULE_NAME="github.com/GoCodeAlone/modular" fi echo "Announcing ${MODULE_NAME}@${VERSION} to Go proxy..." diff --git a/API_CONTRACT_MANAGEMENT.md b/API_CONTRACT_MANAGEMENT.md index c007327a..ba712ee3 100644 --- a/API_CONTRACT_MANAGEMENT.md +++ b/API_CONTRACT_MANAGEMENT.md @@ -78,7 +78,7 @@ modcli contract extract . modcli contract extract ./modules/auth # Extract from remote package -modcli contract extract github.com/CrisisTextLine/modular +modcli contract extract github.com/GoCodeAlone/modular # Save to file with verbose output modcli contract extract . -o contract.json -v diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9b43149f..f022496b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -968,8 +968,8 @@ import ( "fmt" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/database" ) func main() { @@ -1423,7 +1423,7 @@ The Modular framework provides several debugging utilities to help diagnose comm Use `DebugModuleInterfaces` to check which interfaces a specific module implements: ```go -import "github.com/CrisisTextLine/modular" +import "github.com/GoCodeAlone/modular" // Debug a specific module modular.DebugModuleInterfaces(app, "your-module-name") @@ -1534,7 +1534,7 @@ modular.CompareModuleInstances(originalModule, currentModule, "module-name") For detailed analysis of why a module doesn't implement Startable: ```go -import "github.com/CrisisTextLine/modular" +import "github.com/GoCodeAlone/modular" // Check specific module modular.CheckModuleStartableImplementation(yourModule) diff --git a/GO_MODULE_VERSIONING.md b/GO_MODULE_VERSIONING.md index a96c250a..b356994a 100644 --- a/GO_MODULE_VERSIONING.md +++ b/GO_MODULE_VERSIONING.md @@ -14,7 +14,7 @@ Go modules follow semantic versioning (semver) with a special requirement for ma ```go // go.mod -module github.com/CrisisTextLine/modular +module github.com/GoCodeAlone/modular ``` Tags: @@ -27,7 +27,7 @@ Tags: ```go // go.mod for v2 -module github.com/CrisisTextLine/modular/v2 +module github.com/GoCodeAlone/modular/v2 ``` Tags: @@ -79,7 +79,7 @@ This two-step approach provides several benefits: **Initial State (v1.x.x):** ```go // modules/reverseproxy/go.mod -module github.com/CrisisTextLine/modular/modules/reverseproxy +module github.com/GoCodeAlone/modular/modules/reverseproxy ``` **Step 1: Trigger v2.0.0 Release** @@ -99,7 +99,7 @@ When you trigger a release for v2.0.0, the workflow: After merging the PR: ```go // modules/reverseproxy/go.mod (now updated) -module github.com/CrisisTextLine/modular/modules/reverseproxy/v2 +module github.com/GoCodeAlone/modular/modules/reverseproxy/v2 ``` Re-run the same release workflow, and it will: @@ -107,7 +107,7 @@ Re-run the same release workflow, and it will: 2. Skip the PR creation 3. Create tag `modules/reverseproxy/v2.0.0` 4. Generate release with changelog -5. Announce `github.com/CrisisTextLine/modular/modules/reverseproxy/v2@v2.0.0` to Go proxy +5. Announce `github.com/GoCodeAlone/modular/modules/reverseproxy/v2@v2.0.0` to Go proxy ## Manual Version Updates @@ -117,11 +117,11 @@ If you need to manually prepare for a v2+ release: ```bash # 1. Update go.mod -sed -i 's|^module github.com/CrisisTextLine/modular$|module github.com/CrisisTextLine/modular/v2|' go.mod +sed -i 's|^module github.com/GoCodeAlone/modular$|module github.com/GoCodeAlone/modular/v2|' go.mod # 2. Update import paths in all .go files (if any self-imports) find . -name "*.go" -type f -not -path "*/modules/*" -not -path "*/examples/*" \ - -exec sed -i 's|github.com/CrisisTextLine/modular"|github.com/CrisisTextLine/modular/v2"|g' {} + + -exec sed -i 's|github.com/GoCodeAlone/modular"|github.com/GoCodeAlone/modular/v2"|g' {} + # 3. Run go mod tidy go mod tidy @@ -137,12 +137,12 @@ MODULE_NAME="reverseproxy" # Change this to your module name MAJOR_VERSION="2" # Change to your target major version # 1. Update go.mod -sed -i "s|^module github.com/CrisisTextLine/modular/modules/${MODULE_NAME}$|module github.com/CrisisTextLine/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}|" \ +sed -i "s|^module github.com/GoCodeAlone/modular/modules/${MODULE_NAME}$|module github.com/GoCodeAlone/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}|" \ modules/${MODULE_NAME}/go.mod # 2. Update import paths (if module has self-imports - rare) find modules/${MODULE_NAME} -name "*.go" -type f \ - -exec sed -i "s|github.com/CrisisTextLine/modular/modules/${MODULE_NAME}\"|github.com/CrisisTextLine/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}\"|g" {} + + -exec sed -i "s|github.com/GoCodeAlone/modular/modules/${MODULE_NAME}\"|github.com/GoCodeAlone/modular/modules/${MODULE_NAME}/v${MAJOR_VERSION}\"|g" {} + # 3. Run go mod tidy cd modules/${MODULE_NAME} @@ -158,20 +158,20 @@ When using v2+ versions in your code: ```go // For v1.x.x -import "github.com/CrisisTextLine/modular/modules/reverseproxy" +import "github.com/GoCodeAlone/modular/modules/reverseproxy" // For v2.x.x -import "github.com/CrisisTextLine/modular/modules/reverseproxy/v2" +import "github.com/GoCodeAlone/modular/modules/reverseproxy/v2" // For v3.x.x -import "github.com/CrisisTextLine/modular/modules/reverseproxy/v3" +import "github.com/GoCodeAlone/modular/modules/reverseproxy/v3" ``` In `go.mod`: ```go require ( - github.com/CrisisTextLine/modular/v2 v2.0.0 - github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.0.0 + github.com/GoCodeAlone/modular/v2 v2.0.0 + github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.0.0 ) ``` diff --git a/PRIORITY_SYSTEM_GUIDE.md b/PRIORITY_SYSTEM_GUIDE.md index b2314c1d..507457e5 100644 --- a/PRIORITY_SYSTEM_GUIDE.md +++ b/PRIORITY_SYSTEM_GUIDE.md @@ -9,7 +9,7 @@ The Modular framework now supports explicit priority control for configuration f ### Basic Usage ```go -import "github.com/CrisisTextLine/modular/feeders" +import "github.com/GoCodeAlone/modular/feeders" // Add feeders with priority control config.AddFeeder(feeders.NewYamlFeeder("config.yaml").WithPriority(50)) diff --git a/README.md b/README.md index 65f1b918..d66728d1 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # modular Modular Go -[![GitHub License](https://img.shields.io/github/license/CrisisTextLine/modular)](https://github.com/CrisisTextLine/modular/blob/main/LICENSE) -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular) -[![CodeQL](https://github.com/CrisisTextLine/modular/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/github-code-scanning/codeql) -[![Dependabot Updates](https://github.com/CrisisTextLine/modular/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/dependabot/dependabot-updates) -[![CI](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml) -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) -[![Examples CI](https://github.com/CrisisTextLine/modular/actions/workflows/examples-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/examples-ci.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/CrisisTextLine/modular)](https://goreportcard.com/report/github.com/CrisisTextLine/modular) -[![codecov](https://codecov.io/gh/CrisisTextLine/modular/graph/badge.svg?token=2HCVC9RTN8)](https://codecov.io/gh/CrisisTextLine/modular) +[![GitHub License](https://img.shields.io/github/license/GoCodeAlone/modular)](https://github.com/GoCodeAlone/modular/blob/main/LICENSE) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular) +[![CodeQL](https://github.com/GoCodeAlone/modular/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/github-code-scanning/codeql) +[![Dependabot Updates](https://github.com/GoCodeAlone/modular/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/dependabot/dependabot-updates) +[![CI](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) +[![Examples CI](https://github.com/GoCodeAlone/modular/actions/workflows/examples-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/examples-ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/GoCodeAlone/modular)](https://goreportcard.com/report/github.com/GoCodeAlone/modular) +[![codecov](https://codecov.io/gh/GoCodeAlone/modular/graph/badge.svg?token=2HCVC9RTN8)](https://codecov.io/gh/GoCodeAlone/modular) ## Testing @@ -144,7 +144,7 @@ Visit the [examples directory](./examples/) for detailed documentation, configur ## Installation ```go -go get github.com/CrisisTextLine/modular +go get github.com/GoCodeAlone/modular ``` ## Usage @@ -155,7 +155,7 @@ go get github.com/CrisisTextLine/modular package main import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "log/slog" "os" ) @@ -702,20 +702,20 @@ You can install the CLI tool using one of the following methods: #### Using go install (recommended) ```bash -go install github.com/CrisisTextLine/modular/cmd/modcli@latest +go install github.com/GoCodeAlone/modular/cmd/modcli@latest ``` This will download, build, and install the latest version of the CLI tool directly to your GOPATH's bin directory, which should be in your PATH. #### Download pre-built binaries -Download the latest release from the [GitHub Releases page](https://github.com/CrisisTextLine/modular/releases) and add it to your PATH. +Download the latest release from the [GitHub Releases page](https://github.com/GoCodeAlone/modular/releases) and add it to your PATH. #### Build from source ```bash # Clone the repository -git clone https://github.com/CrisisTextLine/modular.git +git clone https://github.com/GoCodeAlone/modular.git cd modular/cmd/modcli # Build the CLI tool diff --git a/application.go b/application.go index 854f2285..c73a3157 100644 --- a/application.go +++ b/application.go @@ -4,11 +4,14 @@ import ( "context" "errors" "fmt" + "maps" "os" "os/signal" "reflect" "slices" "strings" + "sync" + "sync/atomic" "syscall" "time" ) @@ -254,6 +257,21 @@ type Application interface { OnConfigLoaded(hook func(app Application) error) } +// PhaseAware is an optional interface for applications that expose lifecycle phase tracking. +type PhaseAware interface { + Phase() AppPhase +} + +// ReloadableApp is an optional interface for applications that support dynamic config reload. +type ReloadableApp interface { + RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error +} + +// MetricsCollector is an optional interface for applications that aggregate module metrics. +type MetricsCollector interface { + CollectAllMetrics(ctx context.Context) []ModuleMetrics +} + // TenantApplication extends Application with multi-tenant functionality. // This interface adds tenant-aware capabilities to the standard Application, // allowing the same application instance to serve multiple tenants with @@ -336,6 +354,14 @@ type StdApplication struct { configFeeders []Feeder // Optional per-application feeders (override global ConfigFeeders if non-nil) startTime time.Time // Tracks when the application was started configLoadedHooks []func(Application) error // Hooks to run after config loading but before module initialization + dependencyHints []DependencyEdge // Config-driven dependency edges injected via WithModuleDependency + drainTimeout time.Duration // Timeout for pre-stop drain phase + phase atomic.Int32 // Current lifecycle phase (AppPhase) + parallelInit bool // Enable parallel module initialization at same topo depth + initMu sync.Mutex // Guards SetCurrentModule/ClearCurrentModule in parallel init + dynamicReload bool // Enable dynamic reload orchestrator + reloadOrchestrator *ReloadOrchestrator // Coordinates config reload across Reloadable modules + phaseChangeHook func(old, new AppPhase) // Optional hook called on phase transitions (used by ObservableApplication) } // NewStdApplication creates a new application instance with the provided configuration and logger. @@ -482,7 +508,7 @@ func (app *StdApplication) GetService(name string, target any) error { } targetValue := reflect.ValueOf(target) - if targetValue.Kind() != reflect.Ptr || targetValue.IsNil() { + if targetValue.Kind() != reflect.Pointer || targetValue.IsNil() { return ErrTargetNotPointer } @@ -517,7 +543,7 @@ func (app *StdApplication) GetService(name string, target any) error { if serviceType.AssignableTo(targetType) { targetValue.Elem().Set(reflect.ValueOf(service)) return nil - } else if serviceType.Kind() == reflect.Ptr && serviceType.Elem().AssignableTo(targetType) { + } else if serviceType.Kind() == reflect.Pointer && serviceType.Elem().AssignableTo(targetType) { targetValue.Elem().Set(reflect.ValueOf(service).Elem()) return nil } @@ -526,6 +552,110 @@ func (app *StdApplication) GetService(name string, target any) error { ErrServiceIncompatible, name, serviceType, targetType) } +// Phase returns the current lifecycle phase of the application. +func (app *StdApplication) Phase() AppPhase { + return AppPhase(app.phase.Load()) +} + +func (app *StdApplication) setPhase(p AppPhase) { + old := AppPhase(app.phase.Swap(int32(p))) + if app.phaseChangeHook != nil { + app.phaseChangeHook(old, p) + } +} + +// computeDepthLevels groups module names from a topological order into levels +// where modules at the same level have no dependencies on each other and can +// be initialized concurrently. The graph parameter is the fully resolved +// dependency graph (including implicit service dependencies) from resolveDependencies. +func computeDepthLevels(order []string, graph map[string][]string) [][]string { + // Build dependency set per module from the full resolved graph + deps := make(map[string]map[string]bool) + for _, name := range order { + deps[name] = make(map[string]bool) + for _, d := range graph[name] { + deps[name][d] = true + } + } + + placed := make(map[string]bool) + var levels [][]string + + for len(placed) < len(order) { + var level []string + for _, name := range order { + if placed[name] { + continue + } + // Check if all deps are placed + ready := true + for dep := range deps[name] { + if !placed[dep] { + ready = false + break + } + } + if ready { + level = append(level, name) + } + } + if len(level) == 0 { + // No progress — remaining modules have unresolvable dependencies + break + } + for _, name := range level { + placed[name] = true + } + levels = append(levels, level) + } + return levels +} + +// initModule initializes a single module: injects services, calls Init, registers provided services. +// Thread-safe for parallel init: uses RegisterServiceForModule to associate services with the +// correct module without relying on the shared currentModule field. +func (app *StdApplication) initModule(appToPass Application, moduleName string) error { + app.initMu.Lock() + module := app.moduleRegistry[moduleName] + + if _, ok := module.(ServiceAware); ok { + var err error + app.moduleRegistry[moduleName], err = app.injectServices(module) + if err != nil { + app.initMu.Unlock() + return fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err) + } + module = app.moduleRegistry[moduleName] + } + app.initMu.Unlock() + + if err := module.Init(appToPass); err != nil { + return fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err) + } + + // Register provided services with explicit module association (no shared state). + if sa, ok := module.(ServiceAware); ok { + for _, svc := range sa.ProvidesServices() { + if app.enhancedSvcRegistry != nil { + actualName, err := app.enhancedSvcRegistry.RegisterServiceForModule(svc.Name, svc.Instance, module) + if err != nil { + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + app.initMu.Lock() + app.svcRegistry[actualName] = svc.Instance + app.initMu.Unlock() + } else { + if err := app.RegisterService(svc.Name, svc.Instance); err != nil { + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + } + } + } + + app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, module)) + return nil +} + // Init initializes the application with the provided modules func (app *StdApplication) Init() error { return app.InitWithApp(app) @@ -543,6 +673,8 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { return nil } + app.setPhase(PhaseInitializing) + errs := make([]error, 0) for name, module := range app.moduleRegistry { configurableModule, ok := module.(Configurable) @@ -583,52 +715,44 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { } // Build dependency graph - moduleOrder, err := app.resolveDependencies() + moduleOrder, depGraph, err := app.resolveDependencies() if err != nil { errs = append(errs, fmt.Errorf("failed to resolve module dependencies: %w", err)) } // Initialize modules in order - for _, moduleName := range moduleOrder { - module := app.moduleRegistry[moduleName] - - if _, ok := module.(ServiceAware); ok { - // Inject required services - app.moduleRegistry[moduleName], err = app.injectServices(module) - if err != nil { - errs = append(errs, fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err)) - continue - } - module = app.moduleRegistry[moduleName] // Update reference after injection - } - - // Set current module context for service registration tracking - if app.enhancedSvcRegistry != nil { - app.enhancedSvcRegistry.SetCurrentModule(module) - } - - if err = module.Init(appToPass); err != nil { - errs = append(errs, fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err)) - continue - } - - if _, ok := module.(ServiceAware); ok { - // Register services provided by modules - for _, svc := range module.(ServiceAware).ProvidesServices() { - if err = app.RegisterService(svc.Name, svc.Instance); err != nil { - // Collect registration errors (e.g., duplicates) for reporting - errs = append(errs, fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err)) - continue + if app.parallelInit { + // Parallel init: group modules by topological depth and init each level concurrently + levels := computeDepthLevels(moduleOrder, depGraph) + for _, level := range levels { + if len(level) == 1 { + if initErr := app.initModule(appToPass, level[0]); initErr != nil { + errs = append(errs, initErr) + } + } else { + var wg sync.WaitGroup + var mu sync.Mutex + for _, moduleName := range level { + wg.Add(1) + go func(name string) { + defer wg.Done() + if initErr := app.initModule(appToPass, name); initErr != nil { + mu.Lock() + errs = append(errs, initErr) + mu.Unlock() + } + }(moduleName) } + wg.Wait() } } - - // Clear current module context - if app.enhancedSvcRegistry != nil { - app.enhancedSvcRegistry.ClearCurrentModule() + } else { + // Sequential init (original behavior) + for _, moduleName := range moduleOrder { + if initErr := app.initModule(appToPass, moduleName); initErr != nil { + errs = append(errs, initErr) + } } - - app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, app.moduleRegistry[moduleName])) } // Initialize tenant configuration after modules have registered their configurations @@ -636,9 +760,24 @@ func (app *StdApplication) InitWithApp(appToPass Application) error { errs = append(errs, fmt.Errorf("failed to initialize tenant configurations: %w", err)) } + // Wire up the ReloadOrchestrator if dynamic reload is enabled + if app.dynamicReload { + var subject Subject + if obsApp, ok := appToPass.(*ObservableApplication); ok { + subject = obsApp + } + app.reloadOrchestrator = NewReloadOrchestrator(app.logger, subject) + for name, module := range app.moduleRegistry { + if reloadable, ok := module.(Reloadable); ok { + app.reloadOrchestrator.RegisterReloadable(name, reloadable) + } + } + } + // Mark as initialized only after completing Init flow if len(errs) == 0 { app.initialized = true + app.setPhase(PhaseInitialized) } return errors.Join(errs...) @@ -676,6 +815,8 @@ func (app *StdApplication) initTenantConfigurations() error { // Start starts the application func (app *StdApplication) Start() error { + app.setPhase(PhaseStarting) + // Record the start time app.startTime = time.Now() @@ -685,7 +826,7 @@ func (app *StdApplication) Start() error { app.cancel = cancel // Start modules in dependency order - modules, err := app.resolveDependencies() + modules, _, err := app.resolveDependencies() if err != nil { return err } @@ -703,13 +844,24 @@ func (app *StdApplication) Start() error { } } + if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Start(ctx) + } + + app.setPhase(PhaseRunning) return nil } // Stop stops the application func (app *StdApplication) Stop() error { + if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Stop() + } + + app.setPhase(PhaseDraining) + // Get modules in reverse dependency order - modules, err := app.resolveDependencies() + modules, _, err := app.resolveDependencies() if err != nil { return err } @@ -717,7 +869,27 @@ func (app *StdApplication) Stop() error { // Reverse the slice slices.Reverse(modules) - // Create timeout context for shutdown + // Phase 1: Drain + drainTimeout := app.drainTimeout + if drainTimeout <= 0 { + drainTimeout = defaultDrainTimeout + } + drainCtx, drainCancel := context.WithTimeout(context.Background(), drainTimeout) + defer drainCancel() + + for _, name := range modules { + module := app.moduleRegistry[name] + if drainable, ok := module.(Drainable); ok { + app.logger.Info("Draining module", "module", name) + if err := drainable.PreStop(drainCtx); err != nil { + app.logger.Error("Error draining module", "module", name, "error", err) + } + } + } + + app.setPhase(PhaseStopping) + + // Phase 2: Stop ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -727,7 +899,6 @@ func (app *StdApplication) Stop() error { module := app.moduleRegistry[name] stoppableModule, ok := module.(Stoppable) if !ok { - app.logger.Debug("Module does not implement Stoppable, skipping", "module", name) continue } app.logger.Info("Stopping module", "module", name) @@ -742,9 +913,19 @@ func (app *StdApplication) Stop() error { app.cancel() } + app.setPhase(PhaseStopped) return lastErr } +// RequestReload enqueues a configuration reload request with the ReloadOrchestrator. +// Returns an error if dynamic reload was not enabled via WithDynamicReload(). +func (app *StdApplication) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error { + if app.reloadOrchestrator == nil { + return ErrDynamicReloadNotEnabled + } + return app.reloadOrchestrator.RequestReload(ctx, trigger, diff) +} + // Run starts the application and blocks until termination func (app *StdApplication) Run() error { // Initialize @@ -1079,7 +1260,7 @@ func (e EdgeType) String() string { } // resolveDependencies returns modules in initialization order -func (app *StdApplication) resolveDependencies() ([]string, error) { +func (app *StdApplication) resolveDependencies() ([]string, map[string][]string, error) { // Create dependency graph and track dependency edges graph := make(map[string][]string) dependencyEdges := make([]DependencyEdge, 0) @@ -1103,6 +1284,18 @@ func (app *StdApplication) resolveDependencies() ([]string, error) { } } + // Merge config-driven dependency hints (validate both endpoints exist) + for _, hint := range app.dependencyHints { + if _, ok := app.moduleRegistry[hint.From]; !ok { + return nil, nil, fmt.Errorf("dependency hint from %q: %w", hint.From, ErrModuleDependencyMissing) + } + if _, ok := app.moduleRegistry[hint.To]; !ok { + return nil, nil, fmt.Errorf("dependency hint to %q: %w", hint.To, ErrModuleDependencyMissing) + } + graph[hint.From] = append(graph[hint.From], hint.To) + dependencyEdges = append(dependencyEdges, hint) + } + // Analyze service dependencies to augment the graph with implicit dependencies serviceEdges := app.addImplicitDependencies(graph) dependencyEdges = append(dependencyEdges, serviceEdges...) @@ -1182,7 +1375,7 @@ func (app *StdApplication) resolveDependencies() ([]string, error) { for _, node := range nodes { if !visited[node] { if err := visit(node); err != nil { - return nil, err + return nil, nil, err } } } @@ -1190,7 +1383,7 @@ func (app *StdApplication) resolveDependencies() ([]string, error) { // log result app.logger.Debug("Module initialization order", "order", result) - return result, nil + return result, graph, nil } // constructCyclePath constructs a detailed cycle path showing the dependency chain @@ -1434,7 +1627,7 @@ func (app *StdApplication) typeImplementsInterface(svcType, interfaceType reflec if svcType.Implements(interfaceType) { return true } - if svcType.Kind() == reflect.Ptr { + if svcType.Kind() == reflect.Pointer { et := svcType.Elem() if et != nil && et.Implements(interfaceType) { return true @@ -1496,10 +1689,8 @@ func (app *StdApplication) addNameBasedDependency( } // Check if dependency already exists - for _, existingDep := range graph[consumerName] { - if existingDep == providerModule { - return nil // Already exists - } + if slices.Contains(graph[consumerName], providerModule) { + return nil // Already exists } // Add the dependency @@ -1549,10 +1740,8 @@ func (app *StdApplication) addInterfaceBasedDependencyWithTypeInfo(match Interfa app.logger.Debug("Adding required self interface dependency to expose unsatisfiable self-requirement", "module", match.Consumer, "interface", match.InterfaceType.Name(), "service", match.ServiceName) } // Check if this dependency already exists - for _, existingDep := range graph[match.Consumer] { - if existingDep == match.Provider { - return nil - } + if slices.Contains(graph[match.Consumer], match.Provider) { + return nil } // Add the dependency (including self-dependencies for cycle detection) @@ -1644,12 +1833,29 @@ func (app *StdApplication) GetModule(name string) Module { // Returns a copy to prevent external modification of the module registry. func (app *StdApplication) GetAllModules() map[string]Module { result := make(map[string]Module, len(app.moduleRegistry)) - for k, v := range app.moduleRegistry { - result[k] = v - } + maps.Copy(result, app.moduleRegistry) return result } +// CollectAllMetrics gathers metrics from all modules implementing MetricsProvider. +func (app *StdApplication) CollectAllMetrics(ctx context.Context) []ModuleMetrics { + // Snapshot module registry under lock to avoid races with parallel init. + app.initMu.Lock() + modules := make([]Module, 0, len(app.moduleRegistry)) + for _, module := range app.moduleRegistry { + modules = append(modules, module) + } + app.initMu.Unlock() + + var results []ModuleMetrics + for _, module := range modules { + if mp, ok := module.(MetricsProvider); ok { + results = append(results, mp.CollectMetrics(ctx)) + } + } + return results +} + // OnConfigLoaded registers a callback to run after config loading but before module initialization. // This allows reconfiguring dependencies based on loaded configuration values. // Multiple hooks can be registered and will be executed in registration order. diff --git a/application_issue_reproduction_test.go b/application_issue_reproduction_test.go index 1e4f90e7..07623a03 100644 --- a/application_issue_reproduction_test.go +++ b/application_issue_reproduction_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/mock" ) diff --git a/application_lifecycle_bdd_test.go b/application_lifecycle_bdd_test.go index 213ad8ba..80fbed44 100644 --- a/application_lifecycle_bdd_test.go +++ b/application_lifecycle_bdd_test.go @@ -37,7 +37,7 @@ type BDDTestContext struct { startError error stopError error moduleStates map[string]bool - servicesFound map[string]interface{} + servicesFound map[string]any } // Test modules for BDD scenarios @@ -87,7 +87,7 @@ type MockTestService struct{} type ConsumerTestModule struct { SimpleTestModule - receivedService interface{} + receivedService any } func (m *ConsumerTestModule) Init(app Application) error { @@ -121,7 +121,7 @@ func (ctx *BDDTestContext) resetContext() { ctx.startError = nil ctx.stopError = nil ctx.moduleStates = make(map[string]bool) - ctx.servicesFound = make(map[string]interface{}) + ctx.servicesFound = make(map[string]any) } func (ctx *BDDTestContext) iHaveANewModularApplication() error { @@ -386,16 +386,16 @@ func (ctx *BDDTestContext) theErrorShouldIndicateCircularDependency() error { // BDDTestLogger for BDD tests type BDDTestLogger struct{} -func (l *BDDTestLogger) Debug(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Info(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Warn(msg string, fields ...interface{}) {} -func (l *BDDTestLogger) Error(msg string, fields ...interface{}) {} +func (l *BDDTestLogger) Debug(msg string, fields ...any) {} +func (l *BDDTestLogger) Info(msg string, fields ...any) {} +func (l *BDDTestLogger) Warn(msg string, fields ...any) {} +func (l *BDDTestLogger) Error(msg string, fields ...any) {} // InitializeScenario initializes the BDD test scenario func InitializeScenario(ctx *godog.ScenarioContext) { testCtx := &BDDTestContext{ moduleStates: make(map[string]bool), - servicesFound: make(map[string]interface{}), + servicesFound: make(map[string]any), } // Reset context before each scenario diff --git a/application_logger_test.go b/application_logger_test.go index 9e2fb81a..3de60068 100644 --- a/application_logger_test.go +++ b/application_logger_test.go @@ -76,7 +76,7 @@ func Test_ApplicationSetLoggerRuntimeUsage(t *testing.T) { // Create a new mock logger to switch to newMockLogger := &MockLogger{} // Set up a simple expectation that might be called later - newMockLogger.On("Debug", "Test message", []interface{}{"key", "value"}).Return().Maybe() + newMockLogger.On("Debug", "Test message", []any{"key", "value"}).Return().Maybe() // Switch to the new logger app.SetLogger(newMockLogger) @@ -120,9 +120,9 @@ func TestSetVerboseConfig(t *testing.T) { // Set up expectations for debug messages if tt.enabled { - mockLogger.On("Debug", "Verbose configuration debugging enabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging enabled", []any(nil)).Return() } else { - mockLogger.On("Debug", "Verbose configuration debugging disabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging disabled", []any(nil)).Return() } // Create application with mock logger @@ -165,14 +165,14 @@ func TestIsVerboseConfig(t *testing.T) { } // Test after enabling - mockLogger.On("Debug", "Verbose configuration debugging enabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging enabled", []any(nil)).Return() app.SetVerboseConfig(true) if app.IsVerboseConfig() != true { t.Error("Expected IsVerboseConfig to return true after enabling") } // Test after disabling - mockLogger.On("Debug", "Verbose configuration debugging disabled", []interface{}(nil)).Return() + mockLogger.On("Debug", "Verbose configuration debugging disabled", []any(nil)).Return() app.SetVerboseConfig(false) if app.IsVerboseConfig() != false { t.Error("Expected IsVerboseConfig to return false after disabling") diff --git a/application_module_mgmt_test.go b/application_module_mgmt_test.go index 774c14c7..44dd897e 100644 --- a/application_module_mgmt_test.go +++ b/application_module_mgmt_test.go @@ -74,7 +74,7 @@ func Test_ResolveDependencies(t *testing.T) { } // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if (err != nil) != tt.wantErr { t.Errorf("resolveDependencies() error = %v, wantErr %v", err, tt.wantErr) diff --git a/application_observer.go b/application_observer.go index 0e492269..019282d7 100644 --- a/application_observer.go +++ b/application_observer.go @@ -29,10 +29,19 @@ type ObservableApplication struct { // all existing functionality. func NewObservableApplication(cp ConfigProvider, logger Logger) *ObservableApplication { stdApp := NewStdApplication(cp, logger).(*StdApplication) - return &ObservableApplication{ + obsApp := &ObservableApplication{ StdApplication: stdApp, observers: make(map[string]*observerRegistration), } + // Wire phase change hook to emit CloudEvents. + stdApp.phaseChangeHook = func(old, new AppPhase) { + evt := NewCloudEvent(EventTypeAppPhaseChanged, "application", map[string]any{ + "old_phase": old.String(), + "new_phase": new.String(), + }, nil) + obsApp.emitEvent(context.Background(), evt) + } + return obsApp } // RegisterObserver adds an observer to receive notifications from the application. @@ -93,7 +102,6 @@ func (app *ObservableApplication) NotifyObservers(ctx context.Context, event clo // Otherwise, notify observers in goroutines to avoid blocking. synchronous := IsSynchronousNotification(ctx) for _, registration := range app.observers { - registration := registration // capture for goroutine // Check if observer is interested in this event type if len(registration.eventTypes) > 0 && !registration.eventTypes[event.Type()] { @@ -163,7 +171,7 @@ func (app *ObservableApplication) RegisterModule(module Module) { // Emit synchronously so tests observing immediate module registration are reliable. ctx := WithSynchronousNotification(context.Background()) - evt := NewModuleLifecycleEvent("application", "module", module.Name(), "", "registered", map[string]interface{}{ + evt := NewModuleLifecycleEvent("application", "module", module.Name(), "", "registered", map[string]any{ "moduleType": getTypeName(module), }) app.emitEvent(ctx, evt) @@ -176,7 +184,7 @@ func (app *ObservableApplication) RegisterService(name string, service any) erro return err } - evt := NewCloudEvent(EventTypeServiceRegistered, "application", map[string]interface{}{ + evt := NewCloudEvent(EventTypeServiceRegistered, "application", map[string]any{ "serviceName": name, "serviceType": getTypeName(service), }, nil) @@ -199,7 +207,7 @@ func (app *ObservableApplication) Init() error { // Historically the framework emitted config loaded/validated events during initialization. // Even though structured lifecycle events now exist, tests (and possibly external observers) // still expect these generic configuration events to appear. - cfgLoaded := NewCloudEvent(EventTypeConfigLoaded, "application", map[string]interface{}{"phase": "init"}, nil) + cfgLoaded := NewCloudEvent(EventTypeConfigLoaded, "application", map[string]any{"phase": "init"}, nil) app.emitEvent(ctx, cfgLoaded) // Register observers for any ObservableModule instances BEFORE calling module Init() @@ -219,17 +227,17 @@ func (app *ObservableApplication) Init() error { app.logger.Debug("ObservableApplication initializing modules with observable application instance") err := app.InitWithApp(app) if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "init", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "init", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } // Backward compatibility: emit legacy config.validated event after successful initialization. - cfgValidated := NewCloudEvent(EventTypeConfigValidated, "application", map[string]interface{}{"phase": "init_complete"}, nil) + cfgValidated := NewCloudEvent(EventTypeConfigValidated, "application", map[string]any{"phase": "init_complete"}, nil) app.emitEvent(ctx, cfgValidated) // Emit initialization complete - evtInitComplete := NewModuleLifecycleEvent("application", "application", "", "", "initialized", map[string]interface{}{"phase": "init_complete"}) + evtInitComplete := NewModuleLifecycleEvent("application", "application", "", "", "initialized", map[string]any{"phase": "init_complete"}) app.emitEvent(ctx, evtInitComplete) return nil @@ -241,7 +249,7 @@ func (app *ObservableApplication) Start() error { err := app.StdApplication.Start() if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "start", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "start", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } @@ -259,7 +267,7 @@ func (app *ObservableApplication) Stop() error { err := app.StdApplication.Stop() if err != nil { - failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]interface{}{"phase": "stop", "error": err.Error()}) + failureEvt := NewModuleLifecycleEvent("application", "application", "", "", "failed", map[string]any{"phase": "stop", "error": err.Error()}) app.emitEvent(ctx, failureEvt) return err } @@ -272,7 +280,7 @@ func (app *ObservableApplication) Stop() error { } // getTypeName returns the type name of an interface{} value -func getTypeName(v interface{}) string { +func getTypeName(v any) string { if v == nil { return "nil" } diff --git a/application_observer_test.go b/application_observer_test.go index 808062bb..541a4f06 100644 --- a/application_observer_test.go +++ b/application_observer_test.go @@ -337,28 +337,28 @@ type TestObserverLogger struct { type LogEntry struct { Level string Message string - Args []interface{} + Args []any } -func (l *TestObserverLogger) Info(msg string, args ...interface{}) { +func (l *TestObserverLogger) Info(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "INFO", Message: msg, Args: args}) } -func (l *TestObserverLogger) Error(msg string, args ...interface{}) { +func (l *TestObserverLogger) Error(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "ERROR", Message: msg, Args: args}) } -func (l *TestObserverLogger) Debug(msg string, args ...interface{}) { +func (l *TestObserverLogger) Debug(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "DEBUG", Message: msg, Args: args}) } -func (l *TestObserverLogger) Warn(msg string, args ...interface{}) { +func (l *TestObserverLogger) Warn(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, LogEntry{Level: "WARN", Message: msg, Args: args}) diff --git a/application_service_registry_test.go b/application_service_registry_test.go index b8b5ab7b..8e3070de 100644 --- a/application_service_registry_test.go +++ b/application_service_registry_test.go @@ -50,7 +50,7 @@ func Test_GetService(t *testing.T) { tests := []struct { name string serviceName string - target interface{} + target any wantErr bool errCheck func(error) bool }{ diff --git a/base_config_support.go b/base_config_support.go index 4a56c6e5..d1482b77 100644 --- a/base_config_support.go +++ b/base_config_support.go @@ -3,7 +3,7 @@ package modular import ( "os" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // BaseConfigOptions holds configuration for base config support diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 00000000..555cee5a --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,130 @@ +package modular + +import ( + "context" + "fmt" + "testing" + "time" +) + +// --- Benchmark helpers --- + +// benchModule is a minimal Module for bootstrap benchmarks. +type benchModule struct{ name string } + +func (m *benchModule) Name() string { return m.name } +func (m *benchModule) Init(_ Application) error { return nil } + +// benchReloadable is a fast Reloadable for reload benchmarks. +type benchReloadable struct{ name string } + +func (m *benchReloadable) Name() string { return m.name } +func (m *benchReloadable) Init(_ Application) error { return nil } +func (m *benchReloadable) Reload(_ context.Context, _ []ConfigChange) error { + return nil +} +func (m *benchReloadable) CanReload() bool { return true } +func (m *benchReloadable) ReloadTimeout() time.Duration { return 5 * time.Second } + +// benchLogger is a no-op logger for benchmarks. +type benchLogger struct{} + +func (l *benchLogger) Info(_ string, _ ...any) {} +func (l *benchLogger) Error(_ string, _ ...any) {} +func (l *benchLogger) Warn(_ string, _ ...any) {} +func (l *benchLogger) Debug(_ string, _ ...any) {} + +// BenchmarkBootstrap measures Init time with 10 modules. Target: <150ms. +func BenchmarkBootstrap(b *testing.B) { + modules := make([]Module, 10) + for i := range modules { + modules[i] = &benchModule{name: fmt.Sprintf("bench-mod-%d", i)} + } + + b.ResetTimer() + for b.Loop() { + app, err := NewApplication( + WithLogger(&benchLogger{}), + WithConfigProvider(NewStdConfigProvider(&struct{}{})), + WithModules(modules...), + ) + if err != nil { + b.Fatalf("NewApplication failed: %v", err) + } + + if err := app.Init(); err != nil { + b.Fatalf("Init failed: %v", err) + } + } +} + +// BenchmarkServiceLookup measures service registry lookup. Target: <2us. +func BenchmarkServiceLookup(b *testing.B) { + registry := NewEnhancedServiceRegistry() + _, _ = registry.RegisterService("bench-service", &struct{ Value int }{42}) + + b.ResetTimer() + for b.Loop() { + _, _ = registry.GetService("bench-service") + } +} + +// BenchmarkReload measures a single reload cycle with 5 modules. Target: <80ms. +func BenchmarkReload(b *testing.B) { + log := &benchLogger{} + orchestrator := NewReloadOrchestrator(log, nil) + + for i := range 5 { + mod := &benchReloadable{name: fmt.Sprintf("reload-mod-%d", i)} + orchestrator.RegisterReloadable(mod.name, mod) + } + + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key1": {OldValue: "a", NewValue: "b", FieldPath: "key1", ChangeType: ChangeModified}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + + ctx := context.Background() + + b.ResetTimer() + for b.Loop() { + req := ReloadRequest{ + Trigger: ReloadManual, + Diff: diff, + Ctx: ctx, + } + // Call processReload directly to measure the actual reload cycle + // without channel/goroutine overhead. + if err := orchestrator.processReload(ctx, req); err != nil { + b.Fatalf("processReload failed: %v", err) + } + } +} + +// BenchmarkHealthAggregation measures health check aggregation with 10 providers. +// Target: <5ms. +func BenchmarkHealthAggregation(b *testing.B) { + svc := NewAggregateHealthService(WithCacheTTL(0)) + + for i := range 10 { + name := fmt.Sprintf("provider-%d", i) + provider := NewSimpleHealthProvider(name, "main", func(_ context.Context) (HealthStatus, string, error) { + return StatusHealthy, "ok", nil + }) + svc.AddProvider(name, provider) + } + + // Force refresh on every call by using ForceHealthRefreshKey. + ctx := context.WithValue(context.Background(), ForceHealthRefreshKey, true) + + b.ResetTimer() + for b.Loop() { + _, err := svc.Check(ctx) + if err != nil { + b.Fatalf("Check failed: %v", err) + } + } +} diff --git a/builder.go b/builder.go index b03b1e88..743617c4 100644 --- a/builder.go +++ b/builder.go @@ -2,6 +2,8 @@ package modular import ( "context" + "fmt" + "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -21,6 +23,13 @@ type ApplicationBuilder struct { enableObserver bool enableTenant bool configLoadedHooks []func(Application) error // Hooks to run after config loading + tenantGuard *StandardTenantGuard + tenantGuardConfig *TenantGuardConfig + dependencyHints []DependencyEdge + drainTimeout time.Duration + parallelInit bool + dynamicReload bool + plugins []Plugin } // ObserverFunc is a functional observer that can be registered with the application @@ -97,6 +106,83 @@ func (b *ApplicationBuilder) Build() (Application, error) { app = NewObservableDecorator(app, b.observers...) } + // Create and register tenant guard if configured. + // Use RegisterService so that the EnhancedServiceRegistry (if enabled) tracks + // the entry and subsequent RegisterService calls don't overwrite it. + if b.tenantGuardConfig != nil { + b.tenantGuard = NewStandardTenantGuard(*b.tenantGuardConfig) + if err := app.RegisterService("tenant.guard", b.tenantGuard); err != nil { + return nil, fmt.Errorf("failed to register tenant guard service: %w", err) + } + } + + // Unwrap decorators to find the underlying StdApplication. + baseApp := app + for { + if dec, ok := baseApp.(ApplicationDecorator); ok { + if inner := dec.GetInnerApplication(); inner != nil { + baseApp = inner + continue + } + } + break + } + + // Propagate config-driven dependency hints + if len(b.dependencyHints) > 0 { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.dependencyHints = b.dependencyHints + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.dependencyHints = b.dependencyHints + } + } + + // Propagate drain timeout + if b.drainTimeout > 0 { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.drainTimeout = b.drainTimeout + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.drainTimeout = b.drainTimeout + } + } + + // Propagate dynamic reload + if b.dynamicReload { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.dynamicReload = true + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.dynamicReload = true + } + } + + // Propagate parallel init + if b.parallelInit { + if stdApp, ok := baseApp.(*StdApplication); ok { + stdApp.parallelInit = true + } else if obsApp, ok := baseApp.(*ObservableApplication); ok { + obsApp.parallelInit = true + } + } + + // Process plugins + for _, plugin := range b.plugins { + for _, mod := range plugin.Modules() { + app.RegisterModule(mod) + } + if withSvc, ok := plugin.(PluginWithServices); ok { + for _, svcDef := range withSvc.Services() { + if err := app.RegisterService(svcDef.Name, svcDef.Service); err != nil { + return nil, fmt.Errorf("plugin %q service %q: %w", plugin.Name(), svcDef.Name, err) + } + } + } + if withHooks, ok := plugin.(PluginWithHooks); ok { + for _, hook := range withHooks.InitHooks() { + app.OnConfigLoaded(hook) + } + } + } + // Register modules for _, module := range b.modules { app.RegisterModule(module) @@ -142,6 +228,53 @@ func WithModules(modules ...Module) Option { } } +// WithModuleDependency declares that module `from` depends on module `to`, +// injecting an edge into the dependency graph before resolution. +func WithModuleDependency(from, to string) Option { + return func(b *ApplicationBuilder) error { + b.dependencyHints = append(b.dependencyHints, DependencyEdge{ + From: from, + To: to, + Type: EdgeTypeModule, + }) + return nil + } +} + +// WithDrainTimeout sets the timeout for the pre-stop drain phase during shutdown. +func WithDrainTimeout(d time.Duration) Option { + return func(b *ApplicationBuilder) error { + b.drainTimeout = d + return nil + } +} + +// WithParallelInit enables concurrent module initialization at the same topological depth. +func WithParallelInit() Option { + return func(b *ApplicationBuilder) error { + b.parallelInit = true + return nil + } +} + +// WithDynamicReload enables the ReloadOrchestrator, which coordinates +// configuration reloading across all registered Reloadable modules. +func WithDynamicReload() Option { + return func(b *ApplicationBuilder) error { + b.dynamicReload = true + return nil + } +} + +// WithPlugins adds plugins to the application. Each plugin's modules, services, +// and init hooks are registered during Build(). +func WithPlugins(plugins ...Plugin) Option { + return func(b *ApplicationBuilder) error { + b.plugins = append(b.plugins, plugins...) + return nil + } +} + // WithConfigDecorators adds configuration decorators func WithConfigDecorators(decorators ...ConfigDecorator) Option { return func(b *ApplicationBuilder) error { @@ -194,6 +327,26 @@ func WithOnConfigLoaded(hooks ...func(Application) error) Option { } } +// WithTenantGuardMode enables the tenant guard with the specified mode using default config. +func WithTenantGuardMode(mode TenantGuardMode) Option { + return func(b *ApplicationBuilder) error { + if b.tenantGuardConfig == nil { + cfg := DefaultTenantGuardConfig() + b.tenantGuardConfig = &cfg + } + b.tenantGuardConfig.Mode = mode + return nil + } +} + +// WithTenantGuardConfig enables the tenant guard with a full configuration. +func WithTenantGuardConfig(config TenantGuardConfig) Option { + return func(b *ApplicationBuilder) error { + b.tenantGuardConfig = &config + return nil + } +} + // Convenience functions for creating common decorators // InstanceAwareConfig creates an instance-aware configuration decorator diff --git a/builder_dependency_test.go b/builder_dependency_test.go new file mode 100644 index 00000000..29086e05 --- /dev/null +++ b/builder_dependency_test.go @@ -0,0 +1,62 @@ +package modular + +import ( + "testing" +) + +type testDepModule struct { + name string + initSeq *[]string +} + +func (m *testDepModule) Name() string { return m.name } +func (m *testDepModule) Init(app Application) error { + *m.initSeq = append(*m.initSeq, m.name) + return nil +} + +func TestWithModuleDependency_OrdersModulesCorrectly(t *testing.T) { + seq := make([]string, 0) + modA := &testDepModule{name: "alpha", initSeq: &seq} + modB := &testDepModule{name: "beta", initSeq: &seq} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if len(seq) != 2 || seq[0] != "beta" || seq[1] != "alpha" { + t.Errorf("expected init order [beta, alpha], got %v", seq) + } +} + +func TestWithModuleDependency_DetectsCycle(t *testing.T) { + modA := &testDepModule{name: "alpha", initSeq: new([]string)} + modB := &testDepModule{name: "beta", initSeq: new([]string)} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + WithModuleDependency("beta", "alpha"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + err = app.Init() + if err == nil { + t.Fatal("expected circular dependency error") + } + if !IsErrCircularDependency(err) { + t.Errorf("expected ErrCircularDependency, got: %v", err) + } +} diff --git a/cmd/modcli/README.md b/cmd/modcli/README.md index d99f9bc7..71f7e408 100644 --- a/cmd/modcli/README.md +++ b/cmd/modcli/README.md @@ -1,12 +1,12 @@ # ModCLI -[![CI](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/ci.yml) -[![Release](https://github.com/CrisisTextLine/modular/actions/workflows/cli-release.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/cli-release.yml) -[![codecov](https://codecov.io/gh/CrisisTextLine/modular/branch/main/graph/badge.svg?flag=cli)](https://codecov.io/gh/CrisisTextLine/modular) -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/cmd/modcli.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/cmd/modcli) -[![Go Report Card](https://goreportcard.com/badge/github.com/CrisisTextLine/modular)](https://goreportcard.com/report/github.com/CrisisTextLine/modular) +[![CI](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/ci.yml) +[![Release](https://github.com/GoCodeAlone/modular/actions/workflows/cli-release.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/cli-release.yml) +[![codecov](https://codecov.io/gh/GoCodeAlone/modular/branch/main/graph/badge.svg?flag=cli)](https://codecov.io/gh/GoCodeAlone/modular) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/cmd/modcli.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/cmd/modcli) +[![Go Report Card](https://goreportcard.com/badge/github.com/GoCodeAlone/modular)](https://goreportcard.com/report/github.com/GoCodeAlone/modular) -ModCLI is a command-line interface tool for the [Modular](https://github.com/CrisisTextLine/modular) framework that helps you scaffold and generate code for modular applications. +ModCLI is a command-line interface tool for the [Modular](https://github.com/GoCodeAlone/modular) framework that helps you scaffold and generate code for modular applications. ## Installation @@ -15,7 +15,7 @@ ModCLI is a command-line interface tool for the [Modular](https://github.com/Cri Install the latest version directly using Go: ```bash -go install github.com/CrisisTextLine/modular/cmd/modcli@latest +go install github.com/GoCodeAlone/modular/cmd/modcli@latest ``` After installation, the `modcli` command will be available in your PATH. @@ -23,14 +23,14 @@ After installation, the `modcli` command will be available in your PATH. ### From Source ```bash -git clone https://github.com/CrisisTextLine/modular.git +git clone https://github.com/GoCodeAlone/modular.git cd modular/cmd/modcli go install ``` ### From Releases -Download the latest release for your platform from the [releases page](https://github.com/CrisisTextLine/modular/releases). +Download the latest release for your platform from the [releases page](https://github.com/GoCodeAlone/modular/releases). ## Commands diff --git a/cmd/modcli/cmd/contract.go b/cmd/modcli/cmd/contract.go index a055c704..c755089f 100644 --- a/cmd/modcli/cmd/contract.go +++ b/cmd/modcli/cmd/contract.go @@ -8,8 +8,8 @@ import ( "path/filepath" "strings" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/git" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/git" "github.com/spf13/cobra" ) diff --git a/cmd/modcli/cmd/contract_test.go b/cmd/modcli/cmd/contract_test.go index 52c25223..11e4ca90 100644 --- a/cmd/modcli/cmd/contract_test.go +++ b/cmd/modcli/cmd/contract_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" "github.com/spf13/cobra" ) diff --git a/cmd/modcli/cmd/debug_test.go b/cmd/modcli/cmd/debug_test.go index c685b3d5..e084ae4b 100644 --- a/cmd/modcli/cmd/debug_test.go +++ b/cmd/modcli/cmd/debug_test.go @@ -25,7 +25,7 @@ func createTestProject(t testing.TB) string { moduleContent := `package testmodule import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "reflect" ) diff --git a/cmd/modcli/cmd/generate_config_test.go b/cmd/modcli/cmd/generate_config_test.go index 92cb69e9..726caa72 100644 --- a/cmd/modcli/cmd/generate_config_test.go +++ b/cmd/modcli/cmd/generate_config_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/cmd/modcli/cmd/generate_module.go b/cmd/modcli/cmd/generate_module.go index c7490f74..18b88caa 100644 --- a/cmd/modcli/cmd/generate_module.go +++ b/cmd/modcli/cmd/generate_module.go @@ -58,7 +58,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockApplication implements the modular.Application interface for testing @@ -653,7 +653,7 @@ func generateModuleFile(outputDir string, options *ModuleOptions) error { import ( {{if or .HasStartupLogic .HasShutdownLogic}}"context"{{end}} {{/* Conditionally import context */}} - {{if or .HasConfig .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/CrisisTextLine/modular"{{end}} {{/* Conditionally import modular */}} + {{if or .HasConfig .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/GoCodeAlone/modular"{{end}} {{/* Conditionally import modular */}} "log/slog" {{if .HasConfig}}"fmt"{{end}} {{/* Conditionally import fmt */}} {{if or .HasConfig .IsTenantAware}}"encoding/json"{{end}} {{/* For config unmarshaling */}} @@ -1135,7 +1135,7 @@ func generateTestFiles(outputDir string, options *ModuleOptions) error { import ( {{if or .HasStartupLogic .HasShutdownLogic}}"context"{{end}} {{/* Conditionally import context */}} "testing" - {{if or .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/CrisisTextLine/modular"{{end}} {{/* Conditionally import modular */}} + {{if or .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/GoCodeAlone/modular"{{end}} {{/* Conditionally import modular */}} "github.com/stretchr/testify/assert" {{if or .HasConfig .IsTenantAware .ProvidesServices .RequiresServices}}"github.com/stretchr/testify/require"{{end}} {{/* Conditionally import require */}} {{if .IsTenantAware}}"fmt"{{end}} {{/* Import fmt for error formatting in MockTenantService */}} @@ -1318,7 +1318,7 @@ func generateReadmeFile(outputDir string, options *ModuleOptions) error { // Define the template as a raw string to avoid backtick-related syntax issues readmeContent := `# {{.ModuleName}} Module -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework. ## Overview @@ -1342,7 +1342,7 @@ go get github.com/yourusername/{{.PackageName}} package main import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/yourusername/{{.PackageName}}" "log/slog" "os" @@ -1547,7 +1547,7 @@ func generateGoModFile(outputDir string, options *ModuleOptions) error { // } // Add requirements (adjust versions as needed) - if err := newModFile.AddRequire("github.com/CrisisTextLine/modular", "v1.6.0"); err != nil { + if err := newModFile.AddRequire("github.com/GoCodeAlone/modular", "v1.6.0"); err != nil { return fmt.Errorf("failed to add modular requirement: %w", err) } if options.GenerateTests { @@ -1619,11 +1619,11 @@ func generateGoldenGoMod(options *ModuleOptions, goModPath string) error { go 1.25 require ( - github.com/CrisisTextLine/modular v1.6.0 + github.com/GoCodeAlone/modular v1.6.0 github.com/stretchr/testify v1.11.0 ) - replace github.com/CrisisTextLine/modular => ../../../../../../ + replace github.com/GoCodeAlone/modular => ../../../../../../ `, modulePath) err := os.WriteFile(goModPath, []byte(goModContent), 0600) if err != nil { @@ -1700,7 +1700,7 @@ func findParentGoMod() (string, error) { if _, err := os.Stat(goModPath); err == nil { // Check if it's the root go.mod of the modular project itself, if so, skip it content, errRead := os.ReadFile(goModPath) - if errRead == nil && strings.Contains(string(content), "module github.com/CrisisTextLine/modular\\n") { + if errRead == nil && strings.Contains(string(content), "module github.com/GoCodeAlone/modular\\n") { // This is the main project's go.mod, continue searching upwards slog.Debug("Found main project go.mod, continuing search for parent", "path", goModPath) } else { diff --git a/cmd/modcli/cmd/generate_module_test.go b/cmd/modcli/cmd/generate_module_test.go index 3e6ae05f..a4b3ef12 100644 --- a/cmd/modcli/cmd/generate_module_test.go +++ b/cmd/modcli/cmd/generate_module_test.go @@ -14,7 +14,7 @@ import ( "encoding/json" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -825,7 +825,7 @@ func TestGenerateModuleCompiles(t *testing.T) { go 1.21 require ( - github.com/CrisisTextLine/modular v1 + github.com/GoCodeAlone/modular v1 ) ` @@ -840,7 +840,7 @@ import ( "log" "log/slog" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Example function showing how to use the module diff --git a/cmd/modcli/cmd/mock_io_test.go b/cmd/modcli/cmd/mock_io_test.go index 82105054..db299f85 100644 --- a/cmd/modcli/cmd/mock_io_test.go +++ b/cmd/modcli/cmd/mock_io_test.go @@ -5,7 +5,7 @@ import ( "io" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" ) // MockReader is a wrapper around a bytes.Buffer that also implements terminal.FileReader diff --git a/cmd/modcli/cmd/root_test.go b/cmd/modcli/cmd/root_test.go index 8b9d825d..5143b238 100644 --- a/cmd/modcli/cmd/root_test.go +++ b/cmd/modcli/cmd/root_test.go @@ -4,7 +4,7 @@ import ( "bytes" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/stretchr/testify/assert" ) diff --git a/cmd/modcli/cmd/simple_module_test.go b/cmd/modcli/cmd/simple_module_test.go index 81504bbf..ccab45a1 100644 --- a/cmd/modcli/cmd/simple_module_test.go +++ b/cmd/modcli/cmd/simple_module_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md b/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md index 73064e53..a1350f85 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/README.md @@ -1,6 +1,6 @@ # GoldenModule Module -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework. ## Overview @@ -24,7 +24,7 @@ go get github.com/yourusername/goldenmodule package main import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/yourusername/goldenmodule" "log/slog" "os" diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod b/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod index bc611baa..9f7589aa 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/go.mod @@ -1,9 +1,9 @@ module example.com/goldenmodule -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.6.0 + github.com/GoCodeAlone/modular v1.6.0 github.com/stretchr/testify v1.11.1 ) @@ -22,4 +22,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../../../../../ +replace github.com/GoCodeAlone/modular => ../../../../../../ diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go b/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go index 8c8f4b7b..126504ee 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/mock_test.go @@ -5,7 +5,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockApplication implements the modular.Application interface for testing diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go b/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go index d2ea0c33..219a8c8d 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/module.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "log/slog" ) diff --git a/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go b/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go index 61151030..b181dfb5 100644 --- a/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go +++ b/cmd/modcli/cmd/testdata/golden/goldenmodule/module_test.go @@ -3,7 +3,7 @@ package goldenmodule import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" diff --git a/cmd/modcli/go.mod b/cmd/modcli/go.mod index d69fa49b..fd270419 100644 --- a/cmd/modcli/go.mod +++ b/cmd/modcli/go.mod @@ -1,8 +1,8 @@ -module github.com/CrisisTextLine/modular/cmd/modcli +module github.com/GoCodeAlone/modular/cmd/modcli -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 diff --git a/cmd/modcli/internal/contract/differ.go b/cmd/modcli/internal/contract/differ.go index c482d4c1..0302c20c 100644 --- a/cmd/modcli/internal/contract/differ.go +++ b/cmd/modcli/internal/contract/differ.go @@ -294,7 +294,7 @@ func (d *Differ) compareStructFields(old, new TypeContract, diff *ContractDiff) // Check for modified fields (breaking change) for fieldName, newField := range newFields { if oldField, exists := oldFields[fieldName]; exists { - if oldField.Type != newField.Type { + if normalizeType(oldField.Type) != normalizeType(newField.Type) { diff.BreakingChanges = append(diff.BreakingChanges, BreakingChange{ Type: "changed_field_type", Item: fmt.Sprintf("%s.%s", new.Name, fieldName), @@ -455,7 +455,7 @@ func (d *Differ) compareVariables(old, new []VariableContract, diff *ContractDif // Check for modified variable types (breaking change) for name, newVar := range newMap { if oldVar, exists := oldMap[name]; exists { - if oldVar.Type != newVar.Type { + if normalizeType(oldVar.Type) != normalizeType(newVar.Type) { diff.BreakingChanges = append(diff.BreakingChanges, BreakingChange{ Type: "changed_variable_type", Item: name, @@ -506,7 +506,7 @@ func (d *Differ) compareConstants(old, new []ConstantContract, diff *ContractDif // Check for modified constants for name, newConst := range newMap { if oldConst, exists := oldMap[name]; exists { - if oldConst.Type != newConst.Type { + if normalizeType(oldConst.Type) != normalizeType(newConst.Type) { diff.BreakingChanges = append(diff.BreakingChanges, BreakingChange{ Type: "changed_constant_type", Item: name, @@ -570,7 +570,7 @@ func (d *Differ) receiversEqual(old, new *ReceiverInfo) bool { if old == nil || new == nil { return false } - return old.Type == new.Type && old.Pointer == new.Pointer + return normalizeType(old.Type) == normalizeType(new.Type) && old.Pointer == new.Pointer } func (d *Differ) parametersEqual(old, new []ParameterInfo) bool { @@ -580,7 +580,7 @@ func (d *Differ) parametersEqual(old, new []ParameterInfo) bool { for i, oldParam := range old { newParam := new[i] - if oldParam.Type != newParam.Type { + if normalizeType(oldParam.Type) != normalizeType(newParam.Type) { return false } // Note: Parameter names can change without breaking compatibility @@ -589,6 +589,13 @@ func (d *Differ) parametersEqual(old, new []ParameterInfo) bool { return true } +// normalizeType canonicalizes type strings so that aliases +// (e.g. "interface{}" vs "any") are treated as identical. +func normalizeType(t string) string { + t = strings.ReplaceAll(t, "interface{}", "any") + return t +} + // Signature formatting methods func (d *Differ) interfaceSignature(iface InterfaceContract) string { diff --git a/cmd/modcli/internal/git/git.go b/cmd/modcli/internal/git/git.go index 15193fc8..ad5a7b77 100644 --- a/cmd/modcli/internal/git/git.go +++ b/cmd/modcli/internal/git/git.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" ) // GitHelper provides functionality to work with git repositories for contract extraction diff --git a/cmd/modcli/internal/git/git_test.go b/cmd/modcli/internal/git/git_test.go index 0d3cf966..bdd761d2 100644 --- a/cmd/modcli/internal/git/git_test.go +++ b/cmd/modcli/internal/git/git_test.go @@ -6,7 +6,7 @@ import ( "regexp" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/internal/contract" + "github.com/GoCodeAlone/modular/cmd/modcli/internal/contract" ) func TestGitHelper_NewGitHelper(t *testing.T) { diff --git a/cmd/modcli/main.go b/cmd/modcli/main.go index 61cdc9ec..28da7a4b 100644 --- a/cmd/modcli/main.go +++ b/cmd/modcli/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" ) func main() { diff --git a/cmd/modcli/main_test.go b/cmd/modcli/main_test.go index 567ded5b..e17b7838 100644 --- a/cmd/modcli/main_test.go +++ b/cmd/modcli/main_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/cmd/modcli/cmd" + "github.com/GoCodeAlone/modular/cmd/modcli/cmd" ) func TestMainVersionFlag(t *testing.T) { diff --git a/complex_dependencies_test.go b/complex_dependencies_test.go index a3fc413a..faa6ed3c 100644 --- a/complex_dependencies_test.go +++ b/complex_dependencies_test.go @@ -36,7 +36,7 @@ func TestComplexDependencies(t *testing.T) { app.RegisterModule(databaseModule) // No dependencies // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -291,13 +291,13 @@ func (m *APIModule) RequiresServices() []ServiceDependency { Name: "cache", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*CacheService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[CacheService](), }, { Name: "database", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*DatabaseService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[DatabaseService](), }, } } @@ -392,7 +392,7 @@ func (m *AuthModule) RequiresServices() []ServiceDependency { Name: "logger-service", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*LoggingService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[LoggingService](), }, } } diff --git a/config_direct_field_tracking_test.go b/config_direct_field_tracking_test.go index a293df56..21c612eb 100644 --- a/config_direct_field_tracking_test.go +++ b/config_direct_field_tracking_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/config_feeders.go b/config_feeders.go index 1c3f6e00..5afce7fd 100644 --- a/config_feeders.go +++ b/config_feeders.go @@ -1,13 +1,13 @@ package modular import ( - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // Feeder defines the interface for configuration feeders that provide configuration data. type Feeder interface { // Feed gets a struct and feeds it using configuration data. - Feed(structure interface{}) error + Feed(structure any) error } // ConfigFeeders provides a default set of configuration feeders for common use cases @@ -18,14 +18,14 @@ var ConfigFeeders = []Feeder{ // ComplexFeeder extends the basic Feeder interface with additional functionality for complex configuration scenarios type ComplexFeeder interface { Feeder - FeedKey(string, interface{}) error + FeedKey(string, any) error } // InstanceAwareFeeder provides functionality for feeding multiple instances of the same configuration type type InstanceAwareFeeder interface { ComplexFeeder // FeedInstances feeds multiple instances from a map[string]ConfigType - FeedInstances(instances interface{}) error + FeedInstances(instances any) error } // VerboseAwareFeeder provides functionality for verbose debug logging during configuration feeding @@ -47,7 +47,7 @@ type ModuleAwareFeeder interface { // FeedWithModuleContext feeds configuration with module context information. // The moduleName parameter provides the name of the module whose configuration // is being processed, allowing the feeder to customize its behavior accordingly. - FeedWithModuleContext(structure interface{}, moduleName string) error + FeedWithModuleContext(structure any, moduleName string) error } // PrioritizedFeeder extends the Feeder interface with priority control. diff --git a/config_field_tracking.go b/config_field_tracking.go index 1eef7e2f..be6ca6c6 100644 --- a/config_field_tracking.go +++ b/config_field_tracking.go @@ -17,16 +17,16 @@ type FieldTracker interface { // FieldPopulation represents a single field population event type FieldPopulation struct { - FieldPath string // Full path to the field (e.g., "Connections.primary.DSN") - FieldName string // Name of the field - FieldType string // Type of the field - FeederType string // Type of feeder that populated it - SourceType string // Type of source (env, yaml, etc.) - SourceKey string // Source key that was used (e.g., "DB_PRIMARY_DSN") - Value interface{} // Value that was set - InstanceKey string // Instance key for instance-aware fields - SearchKeys []string // All keys that were searched for this field - FoundKey string // The key that was actually found + FieldPath string // Full path to the field (e.g., "Connections.primary.DSN") + FieldName string // Name of the field + FieldType string // Type of the field + FeederType string // Type of feeder that populated it + SourceType string // Type of source (env, yaml, etc.) + SourceKey string // Source key that was used (e.g., "DB_PRIMARY_DSN") + Value any // Value that was set + InstanceKey string // Instance key for instance-aware fields + SearchKeys []string // All keys that were searched for this field + FoundKey string // The key that was actually found } // FieldTrackingFeeder interface allows feeders to support field tracking @@ -132,8 +132,8 @@ func (t *DefaultFieldTracker) GetPopulationsBySource(sourceType string) []FieldP // StructStateDiffer captures before/after states to determine field changes type StructStateDiffer struct { - beforeState map[string]interface{} - afterState map[string]interface{} + beforeState map[string]any + afterState map[string]any tracker FieldTracker logger Logger } @@ -141,15 +141,15 @@ type StructStateDiffer struct { // NewStructStateDiffer creates a new struct state differ func NewStructStateDiffer(tracker FieldTracker, logger Logger) *StructStateDiffer { return &StructStateDiffer{ - beforeState: make(map[string]interface{}), - afterState: make(map[string]interface{}), + beforeState: make(map[string]any), + afterState: make(map[string]any), tracker: tracker, logger: logger, } } // CaptureBeforeState captures the state before feeder processing -func (d *StructStateDiffer) CaptureBeforeState(structure interface{}, prefix string) { +func (d *StructStateDiffer) CaptureBeforeState(structure any, prefix string) { d.captureState(structure, prefix, d.beforeState) if d.logger != nil { d.logger.Debug("Captured before state", "prefix", prefix, "fieldCount", len(d.beforeState)) @@ -157,7 +157,7 @@ func (d *StructStateDiffer) CaptureBeforeState(structure interface{}, prefix str } // CaptureAfterStateAndDiff captures the state after feeder processing and computes diffs -func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure interface{}, prefix string, feederType, sourceType string) { +func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure any, prefix string, feederType, sourceType string) { d.captureState(structure, prefix, d.afterState) if d.logger != nil { d.logger.Debug("Captured after state", "prefix", prefix, "fieldCount", len(d.afterState)) @@ -168,9 +168,9 @@ func (d *StructStateDiffer) CaptureAfterStateAndDiff(structure interface{}, pref } // captureState recursively captures all field values in a structure -func (d *StructStateDiffer) captureState(structure interface{}, prefix string, state map[string]interface{}) { +func (d *StructStateDiffer) captureState(structure any, prefix string, state map[string]any) { rv := reflect.ValueOf(structure) - if rv.Kind() == reflect.Ptr { + if rv.Kind() == reflect.Pointer { if rv.IsNil() { return } @@ -185,7 +185,7 @@ func (d *StructStateDiffer) captureState(structure interface{}, prefix string, s } // captureStructFields recursively captures all field values in a struct -func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, state map[string]interface{}) { +func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, state map[string]any) { rt := rv.Type() for i := 0; i < rv.NumField(); i++ { @@ -204,7 +204,7 @@ func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, switch field.Kind() { case reflect.Struct: d.captureStructFields(field, fieldPath, state) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { d.captureStructFields(field.Elem(), fieldPath, state) } else if !field.IsNil() { @@ -217,7 +217,7 @@ func (d *StructStateDiffer) captureStructFields(rv reflect.Value, prefix string, mapFieldPath := fieldPath + "." + key.String() if mapValue.Kind() == reflect.Struct { d.captureStructFields(mapValue, mapFieldPath, state) - } else if mapValue.Kind() == reflect.Ptr && !mapValue.IsNil() && mapValue.Elem().Kind() == reflect.Struct { + } else if mapValue.Kind() == reflect.Pointer && !mapValue.IsNil() && mapValue.Elem().Kind() == reflect.Struct { d.captureStructFields(mapValue.Elem(), mapFieldPath, state) } else { state[mapFieldPath] = mapValue.Interface() @@ -284,6 +284,6 @@ func (d *StructStateDiffer) computeAndRecordDiffs(feederType, sourceType, instan // Reset clears the captured states for reuse func (d *StructStateDiffer) Reset() { - d.beforeState = make(map[string]interface{}) - d.afterState = make(map[string]interface{}) + d.beforeState = make(map[string]any) + d.afterState = make(map[string]any) } diff --git a/config_field_tracking_implementation_test.go b/config_field_tracking_implementation_test.go index 325da524..af66e2bd 100644 --- a/config_field_tracking_implementation_test.go +++ b/config_field_tracking_implementation_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/config_field_tracking_test.go b/config_field_tracking_test.go index 4ff1c42b..8b1e0f7a 100644 --- a/config_field_tracking_test.go +++ b/config_field_tracking_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -311,7 +311,7 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { // Create mock logger to capture verbose output mockLogger := new(MockLogger) - debugLogs := make([][]interface{}, 0) + debugLogs := make([][]any, 0) mockLogger.On("Debug", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { debugLogs = append(debugLogs, args) }).Return() @@ -396,7 +396,8 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { var secondaryDriverPop, secondaryDSNPop, secondaryMaxConnsPop *FieldPopulation for _, fp := range tracker.FieldPopulations { - if fp.InstanceKey == "primary" { + switch fp.InstanceKey { + case "primary": switch fp.FieldName { case "Driver": primaryDriverPop = &fp @@ -405,7 +406,7 @@ func TestDetailedInstanceAwareFieldTracking(t *testing.T) { case "MaxConns": primaryMaxConnsPop = &fp } - } else if fp.InstanceKey == "secondary" { + case "secondary": switch fp.FieldName { case "Driver": secondaryDriverPop = &fp @@ -470,7 +471,7 @@ func TestConfigDiffBasedFieldTracking(t *testing.T) { tests := []struct { name string envVars map[string]string - expectedFieldDiffs map[string]interface{} // field path -> expected new value + expectedFieldDiffs map[string]any // field path -> expected new value }{ { name: "basic field diff tracking", @@ -478,7 +479,7 @@ func TestConfigDiffBasedFieldTracking(t *testing.T) { "APP_NAME": "Test App", "APP_DEBUG": "true", }, - expectedFieldDiffs: map[string]interface{}{ + expectedFieldDiffs: map[string]any{ "AppName": "Test App", "Debug": true, }, @@ -555,7 +556,7 @@ func TestVerboseDebugFieldVisibility(t *testing.T) { mockLogger := new(MockLogger) // Capture all debug log calls - var debugCalls [][]interface{} + var debugCalls [][]any mockLogger.On("Debug", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { debugCalls = append(debugCalls, args) }).Return() @@ -598,17 +599,17 @@ func TestVerboseDebugFieldVisibility(t *testing.T) { // StructState represents the state of a struct at a point in time type StructState struct { - Fields map[string]interface{} // field path -> value + Fields map[string]any // field path -> value } // captureStructState captures the current state of all fields in a struct -func captureStructState(structure interface{}) *StructState { +func captureStructState(structure any) *StructState { state := &StructState{ - Fields: make(map[string]interface{}), + Fields: make(map[string]any), } rv := reflect.ValueOf(structure) - if rv.Kind() == reflect.Ptr { + if rv.Kind() == reflect.Pointer { rv = rv.Elem() } @@ -617,7 +618,7 @@ func captureStructState(structure interface{}) *StructState { } // captureStructFields recursively captures all field values -func captureStructFields(rv reflect.Value, prefix string, fields map[string]interface{}) { +func captureStructFields(rv reflect.Value, prefix string, fields map[string]any) { rt := rv.Type() for i := 0; i < rv.NumField(); i++ { @@ -632,7 +633,7 @@ func captureStructFields(rv reflect.Value, prefix string, fields map[string]inte switch field.Kind() { case reflect.Struct: captureStructFields(field, fieldPath, fields) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { captureStructFields(field.Elem(), fieldPath, fields) } else if !field.IsNil() { @@ -662,8 +663,8 @@ func captureStructFields(rv reflect.Value, prefix string, fields map[string]inte } // computeFieldDiffs computes the differences between two struct states -func computeFieldDiffs(before, after *StructState) map[string]interface{} { - diffs := make(map[string]interface{}) +func computeFieldDiffs(before, after *StructState) map[string]any { + diffs := make(map[string]any) // Find fields that changed for fieldPath, afterValue := range after.Fields { diff --git a/config_full_flow_field_tracking_test.go b/config_full_flow_field_tracking_test.go index 2a45a303..04a98996 100644 --- a/config_full_flow_field_tracking_test.go +++ b/config_full_flow_field_tracking_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -46,7 +46,7 @@ func createTestConfig() (*Config, FieldTracker, *MockLogger) { } // clearTestEnvironment clears all environment variables that could affect our tests -func clearTestEnvironment(t *testing.T) { +func clearTestEnvironment(_ *testing.T) { // Clear all potential test environment variables testEnvVars := []string{ // Test 1 variables diff --git a/config_provider.go b/config_provider.go index 091577f5..89b329f3 100644 --- a/config_provider.go +++ b/config_provider.go @@ -314,7 +314,7 @@ type Config struct { Feeders []Feeder // StructKeys maps struct identifiers to their configuration objects. // Used internally to track which configuration structures have been processed. - StructKeys map[string]interface{} + StructKeys map[string]any // VerboseDebug enables detailed logging during configuration processing VerboseDebug bool // Logger is used for verbose debug logging @@ -336,7 +336,7 @@ type Config struct { func NewConfig() *Config { return &Config{ Feeders: make([]Feeder, 0), - StructKeys: make(map[string]interface{}), + StructKeys: make(map[string]any), VerboseDebug: false, Logger: nil, FieldTracker: NewDefaultFieldTracker(), @@ -396,7 +396,7 @@ func (c *Config) AddFeeder(feeder Feeder) *Config { } // AddStructKey adds a structure with a key to the configuration -func (c *Config) AddStructKey(key string, target interface{}) *Config { +func (c *Config) AddStructKey(key string, target any) *Config { c.StructKeys[key] = target return c } @@ -420,7 +420,7 @@ func (c *Config) SetFieldTracker(tracker FieldTracker) *Config { // FeedWithModuleContext feeds a single configuration structure with module context information // This allows module-aware feeders to customize their behavior based on the module name -func (c *Config) FeedWithModuleContext(target interface{}, moduleName string) error { +func (c *Config) FeedWithModuleContext(target any, moduleName string) error { if c.VerboseDebug && c.Logger != nil { c.Logger.Debug("Starting module-aware config feed", "targetType", reflect.TypeOf(target), "moduleName", moduleName, "feedersCount", len(c.Feeders)) } @@ -953,7 +953,7 @@ func applyInstanceAwareFeeding(app *StdApplication, tempConfigs map[string]confi // Get the config from the temporary config that was just fed with YAML/ENV data configInfo := tempConfigs[sectionKey] - var tempConfig interface{} + var tempConfig any if configInfo.isPtr { tempConfig = configInfo.tempVal.Interface() } else { @@ -1032,13 +1032,13 @@ type configInfo struct { } // createTempConfig creates a temporary config for feeding values -func createTempConfig(cfg any) (interface{}, configInfo, error) { +func createTempConfig(cfg any) (any, configInfo, error) { if cfg == nil { return nil, configInfo{}, ErrConfigNil } cfgValue := reflect.ValueOf(cfg) - isPtr := cfgValue.Kind() == reflect.Ptr + isPtr := cfgValue.Kind() == reflect.Pointer var targetType reflect.Type var sourceValue reflect.Value @@ -1087,13 +1087,13 @@ func DeepCopyConfig(cfg any) (any, error) { // // This is useful when you need to ensure that modifications to the temporary config // during processing will not affect the original configuration. -func createTempConfigDeep(cfg any) (interface{}, configInfo, error) { +func createTempConfigDeep(cfg any) (any, configInfo, error) { if cfg == nil { return nil, configInfo{}, ErrConfigNil } cfgValue := reflect.ValueOf(cfg) - isPtr := cfgValue.Kind() == reflect.Ptr + isPtr := cfgValue.Kind() == reflect.Pointer var targetType reflect.Type var sourceValue reflect.Value @@ -1133,7 +1133,7 @@ func deepCopyValue(dst, src reflect.Value) { } switch src.Kind() { - case reflect.Ptr: + case reflect.Pointer: if src.IsNil() { return } diff --git a/config_provider_app_loading_test.go b/config_provider_app_loading_test.go index d5caaa22..92ba8ea9 100644 --- a/config_provider_app_loading_test.go +++ b/config_provider_app_loading_test.go @@ -139,8 +139,8 @@ func Test_loadAppConfig(t *testing.T) { mockLogger := new(MockLogger) mockLogger.On("Debug", "Creating new provider with updated config (original was non-pointer)", - []interface{}(nil)).Return() - mockLogger.On("Debug", "Creating new provider for section", []interface{}{"section", "section1"}).Return() + []any(nil)).Return() + mockLogger.On("Debug", "Creating new provider for section", []any{"section", "section1"}).Return() mockLogger.On("Debug", "Added main config for loading", mock.Anything).Return() mockLogger.On("Debug", "Added section config for loading", mock.Anything).Return() mockLogger.On("Debug", "Updated main config", mock.Anything).Return() diff --git a/config_provider_basic_test.go b/config_provider_basic_test.go index b205f0c8..b1c2eedd 100644 --- a/config_provider_basic_test.go +++ b/config_provider_basic_test.go @@ -22,7 +22,7 @@ type MockComplexFeeder struct { mock.Mock } -func (m *MockComplexFeeder) Feed(structure interface{}) error { +func (m *MockComplexFeeder) Feed(structure any) error { args := m.Called(structure) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder error: %w", err) @@ -30,7 +30,7 @@ func (m *MockComplexFeeder) Feed(structure interface{}) error { return nil } -func (m *MockComplexFeeder) FeedKey(key string, target interface{}) error { +func (m *MockComplexFeeder) FeedKey(key string, target any) error { args := m.Called(key, target) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder key error: %w", err) diff --git a/config_provider_temp_config_test.go b/config_provider_temp_config_test.go index 18c49e63..9ec0e27c 100644 --- a/config_provider_temp_config_test.go +++ b/config_provider_temp_config_test.go @@ -156,7 +156,7 @@ func Test_updateConfig(t *testing.T) { mockLogger := new(MockLogger) mockLogger.On("Debug", "Creating new provider with updated config (original was non-pointer)", - []interface{}(nil)).Return() + []any(nil)).Return() app := &StdApplication{ logger: mockLogger, cfgProvider: NewStdConfigProvider(originalCfg), @@ -209,7 +209,7 @@ func Test_updateSectionConfig(t *testing.T) { tempCfgPtr.(*testSectionCfg).Name = "new" mockLogger := new(MockLogger) - mockLogger.On("Debug", "Creating new provider for section", []interface{}{"section", "test"}).Return() + mockLogger.On("Debug", "Creating new provider for section", []any{"section", "test"}).Return() app := &StdApplication{ logger: mockLogger, @@ -288,7 +288,7 @@ func TestDeepCopyValue_Maps(t *testing.T) { t.Run("nil map", func(t *testing.T) { var src map[string]string = nil - dst := reflect.New(reflect.TypeOf(map[string]string{})).Elem() + dst := reflect.New(reflect.TypeFor[map[string]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil map, deepCopyValue returns early without modifying dst @@ -304,7 +304,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("simple slice of integers", func(t *testing.T) { src := []int{1, 2, 3, 4, 5} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[]int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstSlice := dst.Interface().([]int) @@ -318,7 +318,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("slice of strings", func(t *testing.T) { src := []string{"hello", "world"} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstSlice := dst.Interface().([]string) @@ -349,7 +349,7 @@ func TestDeepCopyValue_Slices(t *testing.T) { t.Run("nil slice", func(t *testing.T) { var src []string = nil - dst := reflect.New(reflect.TypeOf([]string{})).Elem() + dst := reflect.New(reflect.TypeFor[[]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil slice, deepCopyValue returns early without modifying dst @@ -366,7 +366,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { str := "original" src := &str - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstPtr := dst.Interface().(*string) @@ -385,7 +385,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { src := &TestStruct{Name: "test", Value: 42} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[*TestStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstPtr := dst.Interface().(*TestStruct) @@ -400,7 +400,7 @@ func TestDeepCopyValue_Pointers(t *testing.T) { t.Run("nil pointer", func(t *testing.T) { var src *string = nil - dst := reflect.New(reflect.TypeOf((*string)(nil))).Elem() + dst := reflect.New(reflect.TypeFor[*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) // For nil pointer, deepCopyValue returns early without modifying dst @@ -421,7 +421,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { src := SimpleStruct{Name: "John", Age: 30} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[SimpleStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(SimpleStruct) @@ -440,7 +440,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Settings: map[string]string{"key1": "value1", "key2": "value2"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ConfigStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(ConfigStruct) @@ -463,7 +463,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Items: []string{"a", "b", "c"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ListStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(ListStruct) @@ -489,7 +489,7 @@ func TestDeepCopyValue_Structs(t *testing.T) { Inner: InnerStruct{Value: 42}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[OuterStruct]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstStruct := dst.Interface().(OuterStruct) @@ -522,7 +522,7 @@ func TestDeepCopyValue_BasicTypes(t *testing.T) { tests := []struct { name string - value interface{} + value any }{ {"int", 42}, {"int64", int64(123456789)}, @@ -567,7 +567,7 @@ func TestDeepCopyValue_ComplexStructures(t *testing.T) { AllowedIPs: []string{"192.168.1.1", "10.0.0.1"}, } - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[ComplexConfig]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstConfig := dst.Interface().(ComplexConfig) @@ -598,7 +598,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { t.Run("array of integers", func(t *testing.T) { src := [5]int{1, 2, 3, 4, 5} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[5]int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([5]int) @@ -612,7 +612,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { t.Run("array of strings", func(t *testing.T) { src := [3]string{"foo", "bar", "baz"} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[3]string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([3]string) @@ -626,7 +626,7 @@ func TestDeepCopyValue_Arrays(t *testing.T) { str1, str2 := "value1", "value2" src := [2]*string{&str1, &str2} - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[[2]*string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstArray := dst.Interface().([2]*string) @@ -644,7 +644,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Parallel() t.Run("interface with concrete string", func(t *testing.T) { - var src interface{} = "hello" + var src any = "hello" dst := reflect.New(reflect.TypeOf(src)).Elem() deepCopyValue(dst, reflect.ValueOf(src)) @@ -654,7 +654,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { }) t.Run("interface with concrete map", func(t *testing.T) { - var src interface{} = map[string]int{"a": 1, "b": 2} + var src any = map[string]int{"a": 1, "b": 2} dst := reflect.New(reflect.TypeOf(src)).Elem() deepCopyValue(dst, reflect.ValueOf(src)) @@ -672,7 +672,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Run("struct with interface field", func(t *testing.T) { type ConfigWithInterface struct { Name string - Data interface{} + Data any } src := ConfigWithInterface{ @@ -696,7 +696,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { t.Run("struct with nil interface field", func(t *testing.T) { type ConfigWithInterface struct { Name string - Data interface{} + Data any } src := ConfigWithInterface{ @@ -718,7 +718,7 @@ func TestDeepCopyValue_Interfaces(t *testing.T) { Data map[string]string } - var src interface{} = TestStruct{ + var src any = TestStruct{ Value: 42, Data: map[string]string{"key": "value"}, } @@ -746,7 +746,7 @@ func TestDeepCopyValue_Channels(t *testing.T) { src <- 42 src <- 100 - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[chan int]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstChan := dst.Interface().(chan int) @@ -759,7 +759,7 @@ func TestDeepCopyValue_Channels(t *testing.T) { t.Run("nil channel", func(t *testing.T) { var src chan string = nil - dst := reflect.New(reflect.TypeOf(src)).Elem() + dst := reflect.New(reflect.TypeFor[chan string]()).Elem() deepCopyValue(dst, reflect.ValueOf(src)) dstChan := dst.Interface().(chan string) @@ -809,7 +809,7 @@ func TestDeepCopyValue_Invalid(t *testing.T) { t.Run("invalid value", func(t *testing.T) { var src reflect.Value // Invalid (zero value) - dst := reflect.New(reflect.TypeOf("")).Elem() + dst := reflect.New(reflect.TypeFor[string]()).Elem() // Should not panic require.NotPanics(t, func() { diff --git a/config_provider_test.go b/config_provider_test.go index fe59b1f4..049ed3a3 100644 --- a/config_provider_test.go +++ b/config_provider_test.go @@ -151,15 +151,13 @@ func TestImmutableConfigProvider(t *testing.T) { errors := make(chan error, 100) // 100 concurrent readers - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 100 { + wg.Go(func() { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- fmt.Errorf("config is nil") } - }() + }) } wg.Wait() @@ -178,26 +176,24 @@ func TestImmutableConfigProvider(t *testing.T) { errors := make(chan error, 100) // 50 concurrent readers - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 100; j++ { + for range 50 { + wg.Go(func() { + for range 100 { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- ErrConfigNil return } } - }() + }) } // 10 concurrent updaters - for i := 0; i < 10; i++ { + for i := range 10 { wg.Add(1) go func(id int) { defer wg.Done() - for j := 0; j < 10; j++ { + for j := range 10 { newCfg := &TestConfig{ Host: "example.com", Port: 8080 + id*100 + j, @@ -299,15 +295,13 @@ func TestCopyOnWriteConfigProvider(t *testing.T) { errors := make(chan error, 100) // 100 concurrent readers - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 100 { + wg.Go(func() { cfg := provider.GetConfig().(*TestConfig) if cfg == nil { errors <- fmt.Errorf("config is nil") } - }() + }) } wg.Wait() @@ -331,7 +325,7 @@ func TestCopyOnWriteConfigProvider(t *testing.T) { errors := make(chan error, 50) // 50 concurrent mutable copy requests - for i := 0; i < 50; i++ { + for i := range 50 { wg.Add(1) go func(id int) { defer wg.Done() diff --git a/config_provider_verbose_test.go b/config_provider_verbose_test.go index d9a719df..06b6d9fe 100644 --- a/config_provider_verbose_test.go +++ b/config_provider_verbose_test.go @@ -14,7 +14,7 @@ type MockVerboseAwareFeeder struct { mock.Mock } -func (m *MockVerboseAwareFeeder) Feed(structure interface{}) error { +func (m *MockVerboseAwareFeeder) Feed(structure any) error { args := m.Called(structure) if err := args.Error(0); err != nil { return fmt.Errorf("mock feeder error: %w", err) diff --git a/config_validation.go b/config_validation.go index ceced11d..5d2d4e09 100644 --- a/config_validation.go +++ b/config_validation.go @@ -18,7 +18,7 @@ const ( tagDefault = "default" tagRequired = "required" tagValidate = "validate" - tagDesc = "desc" // Used for generating sample config and documentation + tagDesc = "desc" ) // ConfigValidator is an interface for configuration validation. @@ -68,13 +68,13 @@ type ConfigValidator interface { // // This function is automatically called by the configuration loading system // before validation, but can also be called manually if needed. -func ProcessConfigDefaults(cfg interface{}) error { +func ProcessConfigDefaults(cfg any) error { if cfg == nil { return ErrConfigNil } v := reflect.ValueOf(cfg) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return ErrConfigNotPointer } @@ -108,7 +108,7 @@ func processStructDefaults(v reflect.Value) error { } // Handle pointers to structs - but only if they're already non-nil - if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct { // Don't automatically initialize nil struct pointers // (the previous behavior was automatically creating them) if !field.IsNil() { @@ -136,13 +136,13 @@ func processStructDefaults(v reflect.Value) error { // ValidateConfigRequired checks all struct fields with `required:"true"` tag // and verifies they are not zero/empty values -func ValidateConfigRequired(cfg interface{}) error { +func ValidateConfigRequired(cfg any) error { if cfg == nil { return ErrConfigNil } v := reflect.ValueOf(cfg) - if v.Kind() != reflect.Ptr || v.IsNil() { + if v.Kind() != reflect.Pointer || v.IsNil() { return ErrConfigNotPointer } @@ -186,7 +186,7 @@ func validateRequiredFields(v reflect.Value, prefix string, errors *[]string) { } // Handle pointers to structs - if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { + if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct { if !field.IsNil() { validateRequiredFields(field.Elem(), fieldName, errors) } else if isFieldRequired(&fieldType) { @@ -221,7 +221,7 @@ func isZeroValue(v reflect.Value) bool { return v.Uint() == 0 case reflect.Float32, reflect.Float64: return v.Float() == 0 - case reflect.Interface, reflect.Ptr: + case reflect.Interface, reflect.Pointer: return v.IsNil() case reflect.Invalid: return true @@ -239,7 +239,7 @@ func isZeroValue(v reflect.Value) bool { // setDefaultValue sets a default value from a string to the proper field type func setDefaultValue(field reflect.Value, defaultVal string) error { // Special handling for time.Duration type - if field.Type() == reflect.TypeOf(time.Duration(0)) { + if field.Type() == reflect.TypeFor[time.Duration]() { return setDefaultDuration(field, defaultVal) } @@ -262,7 +262,7 @@ func setDefaultValue(field reflect.Value, defaultVal string) error { case reflect.Map: return setDefaultMap(field, defaultVal) case reflect.Invalid, reflect.Complex64, reflect.Complex128, reflect.Array, - reflect.Chan, reflect.Func, reflect.Interface, reflect.Ptr, reflect.Struct, + reflect.Chan, reflect.Func, reflect.Interface, reflect.Pointer, reflect.Struct, reflect.UnsafePointer: return handleUnsupportedDefaultType(kind) default: @@ -289,7 +289,7 @@ func handleUnsupportedDefaultType(kind reflect.Kind) error { return fmt.Errorf("%w: functions not supported", ErrUnsupportedTypeForDefault) case reflect.Interface: return fmt.Errorf("%w: interfaces not supported", ErrUnsupportedTypeForDefault) - case reflect.Ptr: + case reflect.Pointer: return fmt.Errorf("%w: pointers not supported", ErrUnsupportedTypeForDefault) case reflect.Struct: return fmt.Errorf("%w: structs not supported", ErrUnsupportedTypeForDefault) @@ -391,7 +391,7 @@ func setDefaultInt(field reflect.Value, i int64) error { case reflect.Invalid, reflect.Bool, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, - reflect.Map, reflect.Ptr, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: + reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set int value to %s", ErrIncompatibleFieldKind, field.Kind()) default: return fmt.Errorf("%w: cannot set int value to %s", ErrIncompatibleFieldKind, field.Kind()) @@ -408,7 +408,7 @@ func setDefaultUint(field reflect.Value, u uint64) error { return nil case reflect.Invalid, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, - reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, + reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set uint value to %s", ErrIncompatibleFieldKind, field.Kind()) default: @@ -428,7 +428,7 @@ func setDefaultFloat(field reflect.Value, f float64) error { case reflect.Invalid, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, - reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice, reflect.String, + reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.String, reflect.Struct, reflect.UnsafePointer: return fmt.Errorf("%w: cannot set float value to %s", ErrIncompatibleFieldKind, field.Kind()) default: @@ -438,7 +438,7 @@ func setDefaultFloat(field reflect.Value, f float64) error { // GenerateSampleConfig generates a sample configuration for a config struct // The format parameter can be "yaml", "json", or "toml" -func GenerateSampleConfig(cfg interface{}, format string) ([]byte, error) { +func GenerateSampleConfig(cfg any, format string) ([]byte, error) { if cfg == nil { return nil, ErrConfigNil } @@ -476,10 +476,10 @@ func GenerateSampleConfig(cfg interface{}, format string) ([]byte, error) { } // mapStructFieldsForJSON creates a map with proper JSON field names based on struct tags -func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { - result := make(map[string]interface{}) +func mapStructFieldsForJSON(cfg any) map[string]any { + result := make(map[string]any) v := reflect.ValueOf(cfg) - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { v = v.Elem() } t := v.Type() @@ -508,7 +508,7 @@ func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { switch field.Kind() { //nolint:exhaustive // only handling specific cases we care about case reflect.Struct: result[fieldName] = mapStructFieldsForJSON(field.Interface()) - case reflect.Ptr: + case reflect.Pointer: if !field.IsNil() && field.Elem().Kind() == reflect.Struct { result[fieldName] = mapStructFieldsForJSON(field.Interface()) } else { @@ -524,7 +524,7 @@ func mapStructFieldsForJSON(cfg interface{}) map[string]interface{} { } // SaveSampleConfig generates and saves a sample configuration file -func SaveSampleConfig(cfg interface{}, format, filePath string) error { +func SaveSampleConfig(cfg any, format, filePath string) error { data, err := GenerateSampleConfig(cfg, format) if err != nil { return err @@ -540,7 +540,7 @@ func SaveSampleConfig(cfg interface{}, format, filePath string) error { // 1. Processes default values // 2. Validates required fields // 3. If the config implements ConfigValidator, calls its Validate method -func ValidateConfig(cfg interface{}) error { +func ValidateConfig(cfg any) error { if cfg == nil { return ErrConfigNil } diff --git a/config_validation_test.go b/config_validation_test.go index b9ffce19..3dde20bd 100644 --- a/config_validation_test.go +++ b/config_validation_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -50,8 +50,8 @@ func (c *ValidationTestConfig) Validate() error { func TestProcessConfigDefaults(t *testing.T) { tests := []struct { name string - cfg interface{} - expected interface{} + cfg any + expected any wantErr bool }{ { @@ -116,7 +116,7 @@ func TestProcessConfigDefaults(t *testing.T) { func TestValidateConfigRequired(t *testing.T) { tests := []struct { name string - cfg interface{} + cfg any wantErr bool errorMsg string }{ @@ -182,7 +182,7 @@ func TestValidateConfigRequired(t *testing.T) { func TestValidateConfig(t *testing.T) { tests := []struct { name string - cfg interface{} + cfg any wantErr bool }{ { @@ -242,7 +242,7 @@ func TestGenerateSampleConfig(t *testing.T) { // Test JSON generation jsonData, err := GenerateSampleConfig(cfg, "json") require.NoError(t, err) - var jsonCfg map[string]interface{} + var jsonCfg map[string]any err = json.Unmarshal(jsonData, &jsonCfg) require.NoError(t, err) assert.Equal(t, "Default Name", jsonCfg["name"]) diff --git a/configuration_base_bdd_test.go b/configuration_base_bdd_test.go index e393b33c..0a62cf08 100644 --- a/configuration_base_bdd_test.go +++ b/configuration_base_bdd_test.go @@ -44,7 +44,7 @@ type ConfigBDDTestContext struct { jsonFile string environmentVars map[string]string originalEnvVars map[string]string - configData interface{} + configData any isValid bool validationErrors []string fieldTracker *TestFieldTracker diff --git a/contract_verifier.go b/contract_verifier.go new file mode 100644 index 00000000..918c0635 --- /dev/null +++ b/contract_verifier.go @@ -0,0 +1,225 @@ +package modular + +import ( + "context" + "fmt" + "sync" + "time" +) + +// ContractViolation describes a single violation found during contract verification. +type ContractViolation struct { + Contract string // "reload" or "health" + Rule string // e.g., "must-return-positive-timeout" + Description string + Severity string // "error" or "warning" +} + +// ContractVerifier verifies that implementations of Reloadable and HealthProvider +// satisfy their behavioral contracts beyond what the type system enforces. +type ContractVerifier interface { + VerifyReloadContract(module Reloadable) []ContractViolation + VerifyHealthContract(provider HealthProvider) []ContractViolation +} + +// StandardContractVerifier is the default implementation of ContractVerifier. +type StandardContractVerifier struct{} + +// NewStandardContractVerifier creates a new StandardContractVerifier. +func NewStandardContractVerifier() *StandardContractVerifier { + return &StandardContractVerifier{} +} + +// VerifyReloadContract checks that a Reloadable module satisfies its behavioral contract: +// 1. ReloadTimeout() returns a positive duration +// 2. CanReload() is safe to call concurrently (no panics) +// 3. Reload() with empty changes is idempotent +// 4. Reload() respects context cancellation +func (v *StandardContractVerifier) VerifyReloadContract(module Reloadable) []ContractViolation { + var violations []ContractViolation + + // 1. ReloadTimeout must return a positive duration. + if timeout := module.ReloadTimeout(); timeout <= 0 { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "must-return-positive-timeout", + Description: fmt.Sprintf("ReloadTimeout() returned %v, must be > 0", timeout), + Severity: "error", + }) + } + + // 2. CanReload must be safe to call concurrently (no panics). + if panicked := v.checkCanReloadConcurrency(module); panicked { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "can-reload-must-not-panic", + Description: "CanReload() panicked during concurrent invocation", + Severity: "warning", + }) + } + + // 3. Reload with empty changes should be idempotent. + if err := v.checkReloadIdempotent(module); err != nil { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "empty-reload-must-be-idempotent", + Description: fmt.Sprintf("Reload() with empty changes failed: %v", err), + Severity: "warning", + }) + } + + // 4. Reload must respect context cancellation. + if !v.checkReloadRespectsCancel(module) { + violations = append(violations, ContractViolation{ + Contract: "reload", + Rule: "must-respect-context-cancellation", + Description: "Reload() with cancelled context did not return an error", + Severity: "warning", + }) + } + + return violations +} + +// checkCanReloadConcurrency calls CanReload 100 times concurrently and reports +// whether any invocation panicked. +func (v *StandardContractVerifier) checkCanReloadConcurrency(module Reloadable) bool { + var ( + wg sync.WaitGroup + panicked int32 + mu sync.Mutex + ) + + for range 100 { + wg.Go(func() { + defer func() { + if r := recover(); r != nil { + mu.Lock() + panicked = 1 + mu.Unlock() + } + }() + module.CanReload() + }) + } + wg.Wait() + return panicked != 0 +} + +// checkReloadIdempotent calls Reload with empty changes twice and returns an error +// if either call fails or hangs beyond the timeout. Each call is guarded by a +// goroutine so a misbehaving module cannot block the verifier indefinitely. +func (v *StandardContractVerifier) checkReloadIdempotent(module Reloadable) error { + for i, label := range []string{"first", "second"} { + _ = i + if err := v.runReloadWithGuard(module, label); err != nil { + return err + } + } + return nil +} + +// runReloadWithGuard runs module.Reload in a goroutine and returns an error if +// it fails or exceeds the 5-second timeout. +func (v *StandardContractVerifier) runReloadWithGuard(module Reloadable, label string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + type result struct{ err error } + ch := make(chan result, 1) + go func() { + ch <- result{err: module.Reload(ctx, nil)} + }() + + select { + case r := <-ch: + if r.err != nil { + return fmt.Errorf("%s call: %w", label, r.err) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("%s call: %w", label, ErrReloadTimeout) + } +} + +// checkReloadRespectsCancel calls Reload with an already-cancelled context and +// returns true if Reload returned an error (i.e., it respected the cancellation). +func (v *StandardContractVerifier) checkReloadRespectsCancel(module Reloadable) bool { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + err := module.Reload(ctx, nil) + return err != nil +} + +// VerifyHealthContract checks that a HealthProvider satisfies its behavioral contract: +// 1. HealthCheck returns within 5 seconds +// 2. Reports have non-empty Module field +// 3. Reports have non-empty Component field +// 4. HealthCheck with cancelled context returns an error +func (v *StandardContractVerifier) VerifyHealthContract(provider HealthProvider) []ContractViolation { + var violations []ContractViolation + + // 1 + 2 + 3: Check that HealthCheck returns in time and reports have required fields. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + type result struct { + reports []HealthReport + err error + } + ch := make(chan result, 1) + go func() { + reports, err := provider.HealthCheck(ctx) + ch <- result{reports, err} + }() + + select { + case <-ctx.Done(): + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-return-within-timeout", + Description: "HealthCheck() did not return within 5 seconds", + Severity: "error", + }) + // Can't check fields if we timed out. + return violations + case res := <-ch: + if res.err == nil { + for _, report := range res.reports { + if report.Module == "" { + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-have-module-field", + Description: "HealthReport has empty Module field", + Severity: "error", + }) + } + if report.Component == "" { + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-have-component-field", + Description: "HealthReport has empty Component field", + Severity: "error", + }) + } + } + } + } + + // 4. HealthCheck with cancelled context should return an error. + cancelCtx, cancelFn := context.WithCancel(context.Background()) + cancelFn() + + _, err := provider.HealthCheck(cancelCtx) + if err == nil { + violations = append(violations, ContractViolation{ + Contract: "health", + Rule: "must-respect-context-cancellation", + Description: "HealthCheck() with cancelled context did not return an error", + Severity: "warning", + }) + } + + return violations +} diff --git a/contract_verifier_test.go b/contract_verifier_test.go new file mode 100644 index 00000000..d5520c3d --- /dev/null +++ b/contract_verifier_test.go @@ -0,0 +1,164 @@ +package modular + +import ( + "context" + "testing" + "time" +) + +// --- Mock Reloadable modules for contract tests --- + +// wellBehavedReloadable satisfies all reload contract rules. +type wellBehavedReloadable struct{} + +func (w *wellBehavedReloadable) Reload(ctx context.Context, _ []ConfigChange) error { + if err := ctx.Err(); err != nil { + return err + } + return nil +} +func (w *wellBehavedReloadable) CanReload() bool { return true } +func (w *wellBehavedReloadable) ReloadTimeout() time.Duration { return 5 * time.Second } + +// zeroTimeoutReloadable returns a zero timeout. +type zeroTimeoutReloadable struct{ wellBehavedReloadable } + +func (z *zeroTimeoutReloadable) ReloadTimeout() time.Duration { return 0 } + +// panickyReloadable panics when CanReload is called. +type panickyReloadable struct{ wellBehavedReloadable } + +func (p *panickyReloadable) CanReload() bool { panic("boom") } + +// --- Mock HealthProviders for contract tests --- + +// wellBehavedHealthProvider returns a proper report and respects cancellation. +type wellBehavedHealthProvider struct{} + +func (w *wellBehavedHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return []HealthReport{ + { + Module: "test-module", + Component: "test-component", + Status: StatusHealthy, + Message: "ok", + CheckedAt: time.Now(), + }, + }, nil +} + +// emptyModuleHealthProvider returns a report with empty Module field. +type emptyModuleHealthProvider struct{} + +func (e *emptyModuleHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return []HealthReport{ + { + Module: "", + Component: "comp", + Status: StatusHealthy, + CheckedAt: time.Now(), + }, + }, nil +} + +// cancelIgnoringHealthProvider ignores context cancellation. +type cancelIgnoringHealthProvider struct{} + +func (c *cancelIgnoringHealthProvider) HealthCheck(_ context.Context) ([]HealthReport, error) { + return []HealthReport{ + { + Module: "mod", + Component: "comp", + Status: StatusHealthy, + CheckedAt: time.Now(), + }, + }, nil +} + +// --- Tests --- + +func TestContractVerifier_ReloadWellBehaved(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyReloadContract(&wellBehavedReloadable{}) + if len(violations) != 0 { + t.Fatalf("expected 0 violations for well-behaved reloadable, got %d: %+v", len(violations), violations) + } +} + +func TestContractVerifier_ReloadZeroTimeout(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyReloadContract(&zeroTimeoutReloadable{}) + + found := false + for _, v := range violations { + if v.Rule == "must-return-positive-timeout" && v.Severity == "error" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for zero timeout, got: %+v", violations) + } +} + +func TestContractVerifier_ReloadPanicsOnCanReload(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyReloadContract(&panickyReloadable{}) + + found := false + for _, v := range violations { + if v.Rule == "can-reload-must-not-panic" && v.Severity == "warning" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for panicky CanReload, got: %+v", violations) + } +} + +func TestContractVerifier_HealthWellBehaved(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyHealthContract(&wellBehavedHealthProvider{}) + if len(violations) != 0 { + t.Fatalf("expected 0 violations for well-behaved health provider, got %d: %+v", len(violations), violations) + } +} + +func TestContractVerifier_HealthEmptyModule(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyHealthContract(&emptyModuleHealthProvider{}) + + found := false + for _, v := range violations { + if v.Rule == "must-have-module-field" && v.Severity == "error" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for empty Module field, got: %+v", violations) + } +} + +func TestContractVerifier_HealthIgnoresCancellation(t *testing.T) { + verifier := NewStandardContractVerifier() + violations := verifier.VerifyHealthContract(&cancelIgnoringHealthProvider{}) + + found := false + for _, v := range violations { + if v.Rule == "must-respect-context-cancellation" && v.Severity == "warning" { + found = true + break + } + } + if !found { + t.Fatalf("expected violation for ignoring cancellation, got: %+v", violations) + } +} diff --git a/cycle_detection_modules_bdd_test.go b/cycle_detection_modules_bdd_test.go index e6a822b7..d55b325f 100644 --- a/cycle_detection_modules_bdd_test.go +++ b/cycle_detection_modules_bdd_test.go @@ -26,7 +26,7 @@ func (m *CycleModuleA) RequiresServices() []ServiceDependency { Name: "serviceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceB)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceB](), }} } @@ -50,7 +50,7 @@ func (m *CycleModuleB) RequiresServices() []ServiceDependency { Name: "serviceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -82,7 +82,7 @@ func (m *LinearModuleB) RequiresServices() []ServiceDependency { Name: "linearServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -106,7 +106,7 @@ func (m *SelfDependentModule) RequiresServices() []ServiceDependency { Name: "selfService", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -153,7 +153,7 @@ func (m *MixedDependencyModuleB) RequiresServices() []ServiceDependency { Name: "mixedServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -177,7 +177,7 @@ func (m *ComplexCycleModuleA) RequiresServices() []ServiceDependency { Name: "complexServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceB)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceB](), }} } @@ -201,7 +201,7 @@ func (m *ComplexCycleModuleB) RequiresServices() []ServiceDependency { Name: "complexServiceC", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceC)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceC](), }} } @@ -225,7 +225,7 @@ func (m *ComplexCycleModuleC) RequiresServices() []ServiceDependency { Name: "complexServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterfaceA)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterfaceA](), }} } @@ -249,7 +249,7 @@ func (m *DisambiguationModuleA) RequiresServices() []ServiceDependency { Name: "disambiguationServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*AnotherEnhancedTestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[AnotherEnhancedTestInterface](), }} } @@ -273,6 +273,6 @@ func (m *DisambiguationModuleB) RequiresServices() []ServiceDependency { Name: "disambiguationServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*EnhancedTestInterface)(nil)).Elem(), // Note: different interface + SatisfiesInterface: reflect.TypeFor[EnhancedTestInterface](), // Note: different interface }} } diff --git a/cycle_detection_test.go b/cycle_detection_test.go index 432d6d6e..7ce11033 100644 --- a/cycle_detection_test.go +++ b/cycle_detection_test.go @@ -44,7 +44,7 @@ func (m *CycleTestModuleA) RequiresServices() []ServiceDependency { Name: "testServiceB", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterface](), }, } } @@ -81,7 +81,7 @@ func (m *CycleTestModuleB) RequiresServices() []ServiceDependency { Name: "testServiceA", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestInterface](), }, } } diff --git a/database_interface_matching_test.go b/database_interface_matching_test.go index a0cc5eca..f2474f5c 100644 --- a/database_interface_matching_test.go +++ b/database_interface_matching_test.go @@ -12,9 +12,9 @@ import ( // DatabaseExecutor matches the user's interface from the problem description type DatabaseExecutor interface { - ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) - QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) } @@ -25,15 +25,15 @@ var _ DatabaseExecutor = (*sql.DB)(nil) // mockDatabaseExecutor is a mock implementation for testing type mockDatabaseExecutor struct{} -func (m *mockDatabaseExecutor) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { +func (m *mockDatabaseExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { return &mockResult{}, nil } -func (m *mockDatabaseExecutor) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { +func (m *mockDatabaseExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { return nil, nil } -func (m *mockDatabaseExecutor) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { +func (m *mockDatabaseExecutor) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { return &sql.Row{} } @@ -80,19 +80,19 @@ func TestInterfaceMatchingCore(t *testing.T) { mockService := &mockDatabaseServiceImpl{executor: mockExecutor} // Test 1: Check if mockDatabaseExecutor implements DatabaseExecutor (it should) - expectedType := reflect.TypeOf((*DatabaseExecutor)(nil)).Elem() - mockExecutorType := reflect.TypeOf((*mockDatabaseExecutor)(nil)) + expectedType := reflect.TypeFor[DatabaseExecutor]() + mockExecutorType := reflect.TypeFor[*mockDatabaseExecutor]() assert.True(t, mockExecutorType.Implements(expectedType), "mockDatabaseExecutor should implement DatabaseExecutor interface") // Test 2: Check if mockDatabaseServiceImpl implements DatabaseExecutor (it should NOT) - mockServiceType := reflect.TypeOf((*mockDatabaseServiceImpl)(nil)) + mockServiceType := reflect.TypeFor[*mockDatabaseServiceImpl]() assert.False(t, mockServiceType.Implements(expectedType), "mockDatabaseServiceImpl should NOT implement DatabaseExecutor interface") // Test 3: Check if mockDatabaseServiceImpl implements MockDatabaseService (it should) - mockDBServiceType := reflect.TypeOf((*MockDatabaseService)(nil)).Elem() + mockDBServiceType := reflect.TypeFor[MockDatabaseService]() assert.True(t, mockServiceType.Implements(mockDBServiceType), "mockDatabaseServiceImpl should implement MockDatabaseService interface") diff --git a/debug_module_interfaces.go b/debug_module_interfaces.go index 8d6d53c7..ea12996b 100644 --- a/debug_module_interfaces.go +++ b/debug_module_interfaces.go @@ -23,7 +23,7 @@ func DebugModuleInterfaces(app Application, moduleName string) { fmt.Printf(" Memory address: %p\n", module) // Check all the interfaces - interfaces := map[string]interface{}{ + interfaces := map[string]any{ "Module": (*Module)(nil), "Configurable": (*Configurable)(nil), "DependencyAware": (*DependencyAware)(nil), diff --git a/debug_module_test.go b/debug_module_test.go index d15cae63..35a21705 100644 --- a/debug_module_test.go +++ b/debug_module_test.go @@ -22,7 +22,7 @@ func TestModuleReplacementLosesStartable(t *testing.T) { originalModule := &ProblematicModule{name: "test-module"} // Verify the original module implements Startable - _, implementsStartable := interface{}(originalModule).(Startable) + _, implementsStartable := any(originalModule).(Startable) require.True(t, implementsStartable, "Original module should implement Startable") // Register the module @@ -81,7 +81,7 @@ func TestProperModuleConstructorPattern(t *testing.T) { originalModule := &CorrectModule{name: "correct-module"} // Verify the original module implements Startable - _, implementsStartable := interface{}(originalModule).(Startable) + _, implementsStartable := any(originalModule).(Startable) require.True(t, implementsStartable, "Original module should implement Startable") // Register the module diff --git a/decorator_config.go b/decorator_config.go index f0f9609e..a8522630 100644 --- a/decorator_config.go +++ b/decorator_config.go @@ -25,7 +25,7 @@ type instanceAwareConfigProvider struct { } // GetConfig returns the base configuration -func (p *instanceAwareConfigProvider) GetConfig() interface{} { +func (p *instanceAwareConfigProvider) GetConfig() any { return p.base.GetConfig() } @@ -54,7 +54,7 @@ type tenantAwareConfigProvider struct { } // GetConfig returns the base configuration -func (p *tenantAwareConfigProvider) GetConfig() interface{} { +func (p *tenantAwareConfigProvider) GetConfig() any { return p.base.GetConfig() } @@ -62,7 +62,7 @@ func (p *tenantAwareConfigProvider) GetConfig() interface{} { var errNoTenantLoaderConfigured = errors.New("no tenant loader configured") // GetTenantConfig retrieves configuration for a specific tenant -func (p *tenantAwareConfigProvider) GetTenantConfig(tenantID TenantID) (interface{}, error) { +func (p *tenantAwareConfigProvider) GetTenantConfig(tenantID TenantID) (any, error) { if p.loader == nil { return nil, errNoTenantLoaderConfigured } diff --git a/decorator_observable.go b/decorator_observable.go index fb8d3759..43c11480 100644 --- a/decorator_observable.go +++ b/decorator_observable.go @@ -45,7 +45,7 @@ func (d *ObservableDecorator) RemoveObserver(observer ObserverFunc) { } // emitEvent emits a CloudEvent to all registered observers -func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, data interface{}, metadata map[string]interface{}) { +func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, data any, metadata map[string]any) { event := NewCloudEvent(eventType, "application", data, metadata) d.observerMutex.RLock() @@ -55,7 +55,6 @@ func (d *ObservableDecorator) emitEvent(ctx context.Context, eventType string, d // Notify observers in goroutines to avoid blocking for _, observer := range observers { - observer := observer // capture for goroutine go func() { defer func() { if r := recover(); r != nil { @@ -77,7 +76,7 @@ func (d *ObservableDecorator) Init() error { ctx := context.Background() // Emit before init event - d.emitEvent(ctx, "com.modular.application.before.init", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.init", nil, map[string]any{ "phase": "before_init", "timestamp": time.Now().Format(time.RFC3339), }) @@ -86,9 +85,9 @@ func (d *ObservableDecorator) Init() error { if err != nil { // Emit init failed event - d.emitEvent(ctx, "com.modular.application.init.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.init.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "init_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -96,7 +95,7 @@ func (d *ObservableDecorator) Init() error { } // Emit after init event - d.emitEvent(ctx, "com.modular.application.after.init", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.init", nil, map[string]any{ "phase": "after_init", "timestamp": time.Now().Format(time.RFC3339), }) @@ -109,7 +108,7 @@ func (d *ObservableDecorator) Start() error { ctx := context.Background() // Emit before start event - d.emitEvent(ctx, "com.modular.application.before.start", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.start", nil, map[string]any{ "phase": "before_start", "timestamp": time.Now().Format(time.RFC3339), }) @@ -118,9 +117,9 @@ func (d *ObservableDecorator) Start() error { if err != nil { // Emit start failed event - d.emitEvent(ctx, "com.modular.application.start.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.start.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "start_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -128,7 +127,7 @@ func (d *ObservableDecorator) Start() error { } // Emit after start event - d.emitEvent(ctx, "com.modular.application.after.start", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.start", nil, map[string]any{ "phase": "after_start", "timestamp": time.Now().Format(time.RFC3339), }) @@ -141,7 +140,7 @@ func (d *ObservableDecorator) Stop() error { ctx := context.Background() // Emit before stop event - d.emitEvent(ctx, "com.modular.application.before.stop", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.before.stop", nil, map[string]any{ "phase": "before_stop", "timestamp": time.Now().Format(time.RFC3339), }) @@ -150,9 +149,9 @@ func (d *ObservableDecorator) Stop() error { if err != nil { // Emit stop failed event - d.emitEvent(ctx, "com.modular.application.stop.failed", map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.stop.failed", map[string]any{ "error": err.Error(), - }, map[string]interface{}{ + }, map[string]any{ "phase": "stop_failed", "timestamp": time.Now().Format(time.RFC3339), }) @@ -160,7 +159,7 @@ func (d *ObservableDecorator) Stop() error { } // Emit after stop event - d.emitEvent(ctx, "com.modular.application.after.stop", nil, map[string]interface{}{ + d.emitEvent(ctx, "com.modular.application.after.stop", nil, map[string]any{ "phase": "after_stop", "timestamp": time.Now().Format(time.RFC3339), }) diff --git a/docs/plans/2026-03-09-modular-v2-enhancements-design.md b/docs/plans/2026-03-09-modular-v2-enhancements-design.md new file mode 100644 index 00000000..f391b4c9 --- /dev/null +++ b/docs/plans/2026-03-09-modular-v2-enhancements-design.md @@ -0,0 +1,236 @@ +# Modular v2 Enhancements Design + +**Goal:** Address 12 gaps identified in the Modular framework audit, making it a more complete foundation for the Workflow engine and other consumers. + +**Delivery:** Single PR on the `feat/reimplementation` branch. + +**Consumer:** GoCodeAlone/workflow engine (primary), other Go services using Modular. + +--- + +## Section 1: Core Lifecycle + +### 1.1 Config-Driven Dependency Hints + +**Gap:** Modules can declare dependencies via `DependencyAware` interface, but there's no way to declare them from the builder/config level without modifying module code. + +**Design:** `WithModuleDependency(from, to string)` builder option injects edges into the dependency graph before resolution. These hints feed into the existing topological sort alongside `DependencyAware` edges. + +```go +app := modular.NewApplicationBuilder(). + WithModuleDependency("api-server", "database"). + WithModuleDependency("api-server", "cache"). + Build() +``` + +Implementation: Store hints in `[]DependencyEdge` on the builder, merge into the graph in `resolveDependencies()` before DFS. + +### 1.2 Drainable Interface (Shutdown Drain Phases) + +**Gap:** `Stoppable` has a single `Stop()` method. No way to drain in-flight work before hard stop. + +**Design:** New `Drainable` interface with `PreStop(ctx)` called before `Stop()`: + +```go +type Drainable interface { + PreStop(ctx context.Context) error +} +``` + +Shutdown sequence: `PreStop` all drainable modules (reverse dependency order) → `Stop` all stoppable modules (reverse dependency order). `PreStop` context has a configurable timeout via `WithDrainTimeout(d)`. + +### 1.3 Application Phase Tracking + +**Gap:** No way to query what lifecycle phase the application is in. + +**Design:** `Phase()` method on Application returning an enum: + +```go +type AppPhase int +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) +``` + +Phase transitions emit CloudEvents (`EventTypeAppPhaseChanged`) if a Subject is configured. + +### 1.4 Parallel Init at Same Topological Depth + +**Gap:** Modules at the same depth in the dependency graph are initialized sequentially. + +**Design:** `WithParallelInit()` builder option. When enabled, modules at the same topological depth are initialized concurrently via `errgroup`. Modules at different depths remain sequential (respecting dependency order). + +Disabled by default for backward compatibility. Errors from any goroutine cancel the group and return the first error. + +--- + +## Section 2: Services & Plugins + +### 2.1 Type-Safe Service Helpers + +**Gap:** `RegisterService`/`GetService` use `interface{}`, requiring type assertions at every call site. + +**Design:** Package-level generic helper functions (not methods, since Go interfaces can't have type parameters): + +```go +func RegisterTypedService[T any](registry ServiceRegistry, name string, svc T) error +func GetTypedService[T any](registry ServiceRegistry, name string) (T, error) +``` + +These wrap the existing `RegisterService`/`GetService` with compile-time type safety. `GetTypedService` returns a typed zero value + error on type mismatch. + +### 2.2 Service Readiness Events + +**Gap:** No notification when a service becomes available, making lazy/async resolution brittle. + +**Design:** `EventTypeServiceRegistered` CloudEvent emitted by `EnhancedServiceRegistry.RegisterService()`. Plus `OnServiceReady(name, callback)` method that fires the callback immediately if already registered, or defers until registration. + +```go +registry.OnServiceReady("database", func(svc interface{}) { + db := svc.(*sql.DB) + // use db +}) +``` + +### 2.3 Plugin Interface + +**Gap:** No standard way to bundle modules, services, and hooks as a distributable unit. + +**Design:** Three interfaces with progressive capability: + +```go +type Plugin interface { + Name() string + Modules() []Module +} + +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +type ServiceDefinition struct { + Name string + Service interface{} +} +``` + +Builder gains `WithPlugins(...Plugin)`: registers all modules, runs hooks during init, registers services before module init. + +--- + +## Section 3: Configuration & Reload + +### 3.1 ReloadOrchestrator Integration + +**Gap:** `ReloadOrchestrator` exists but isn't wired into the Application lifecycle. + +**Design:** `WithDynamicReload()` builder option: +- Creates `ReloadOrchestrator` during `Build()` +- Auto-registers all `Reloadable` modules after init +- Calls `Start()` during app start, `Stop()` during app stop +- Exposes `Application.RequestReload(ctx, trigger, diff)` for consumers + +### 3.2 Config File Watcher + +**Gap:** No built-in file watching for configuration changes. + +**Design:** New `modules/configwatcher` package providing a module that watches config files: + +```go +watcher := configwatcher.New( + configwatcher.WithPaths("config/app.yaml", "config/overrides.yaml"), + configwatcher.WithDebounce(500 * time.Millisecond), + configwatcher.WithDiffFunc(myDiffFunc), +) +``` + +Uses `fsnotify` (single new dependency). On change: debounce → compute diff → call `Application.RequestReload()`. Implements `Startable`/`Stoppable` for lifecycle management. + +### 3.3 Secret Resolution Hooks + +**Gap:** Config values like `${vault:secret/db-password}` have no standard expansion mechanism. + +**Design:** `SecretResolver` interface + utility function: + +```go +type SecretResolver interface { + ResolveSecret(ctx context.Context, ref string) (string, error) + CanResolve(ref string) bool +} + +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error +``` + +`ExpandSecrets` walks the config map, finds string values matching `${prefix:path}`, dispatches to the first resolver where `CanResolve` returns true, and replaces in-place. Called by consumers before feeding config to modules. + +--- + +## Section 4: Observability + +### 4.1 Slog Adapter + +**Gap:** Framework uses custom `Logger` interface. Go's `slog` is the standard. + +**Design:** Keep `Logger` interface unchanged. Add `SlogAdapter` implementing `Logger` by wrapping `*slog.Logger`: + +```go +type SlogAdapter struct { + logger *slog.Logger +} + +func NewSlogAdapter(l *slog.Logger) *SlogAdapter +func (a *SlogAdapter) With(args ...any) *SlogAdapter +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter +``` + +`With()`/`WithGroup()` return `*SlogAdapter` (not `Logger`) for chaining structured context. Base `Logger` interface methods (`Info`, `Error`, `Warn`, `Debug`) delegate to slog equivalents. + +### 4.2 Module Metrics Hooks + +**Gap:** No standard way for modules to expose operational metrics. + +**Design:** Optional `MetricsProvider` interface: + +```go +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} +``` + +`Application.CollectAllMetrics(ctx) []ModuleMetrics` iterates modules implementing `MetricsProvider`. No OTEL/Prometheus dependency — returns raw values for consumers to map to their telemetry system. + +--- + +## Gap Matrix Summary + +| # | Gap | Section | Key Types | +|---|-----|---------|-----------| +| 1 | Config-driven dependency hints | 1.1 | `WithModuleDependency` | +| 2 | Shutdown drain phases | 1.2 | `Drainable`, `PreStop` | +| 3 | Application phase tracking | 1.3 | `AppPhase`, `Phase()` | +| 4 | Parallel init | 1.4 | `WithParallelInit` | +| 5 | Type-safe services | 2.1 | `RegisterTypedService[T]` | +| 6 | Service readiness events | 2.2 | `OnServiceReady` | +| 7 | Plugin interface | 2.3 | `Plugin`, `WithPlugins` | +| 8 | Reload orchestrator integration | 3.1 | `WithDynamicReload` | +| 9 | Config file watcher | 3.2 | `configwatcher` module | +| 10 | Secret resolution hooks | 3.3 | `SecretResolver` | +| 11 | Slog adapter | 4.1 | `SlogAdapter` | +| 12 | Module metrics hooks | 4.2 | `MetricsProvider` | diff --git a/docs/plans/2026-03-09-modular-v2-enhancements.md b/docs/plans/2026-03-09-modular-v2-enhancements.md new file mode 100644 index 00000000..eeeb8294 --- /dev/null +++ b/docs/plans/2026-03-09-modular-v2-enhancements.md @@ -0,0 +1,2254 @@ +# Modular v2 Enhancements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement 12 framework enhancements to the GoCodeAlone/modular framework covering lifecycle, services, plugins, configuration, reload, and observability. + +**Architecture:** All changes are in the root `modular` package except the config file watcher (new `modules/configwatcher` subpackage). The existing `Application` interface, `StdApplication` struct, and `ApplicationBuilder` are extended. New interfaces (`Drainable`, `Plugin`, `MetricsProvider`, `SecretResolver`) follow the existing optional-interface pattern. Generic service helpers use Go 1.26 type parameters. + +**Tech Stack:** Go 1.26, CloudEvents SDK, fsnotify (new dependency for configwatcher) + +--- + +### Task 1: Config-Driven Dependency Hints (`WithModuleDependency`) + +**Files:** +- Modify: `builder.go` — add `dependencyHints` field, `WithModuleDependency` option +- Modify: `application.go` — merge hints into `resolveDependencies()` +- Create: `builder_dependency_test.go` — tests +- Modify: `errors.go` — add sentinel if needed + +**Step 1: Write the failing test** + +Create `builder_dependency_test.go`: + +```go +package modular + +import ( + "context" + "testing" +) + +// testDepModule is a minimal module for dependency hint testing. +type testDepModule struct { + name string + initSeq *[]string +} + +func (m *testDepModule) Name() string { return m.name } +func (m *testDepModule) Init(app Application) error { + *m.initSeq = append(*m.initSeq, m.name) + return nil +} + +func TestWithModuleDependency_OrdersModulesCorrectly(t *testing.T) { + seq := make([]string, 0) + modA := &testDepModule{name: "alpha", initSeq: &seq} + modB := &testDepModule{name: "beta", initSeq: &seq} + + // Without dependency hints, alpha inits before beta (alphabetical DFS). + // With WithModuleDependency("alpha", "beta"), beta must init first. + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if len(seq) != 2 || seq[0] != "beta" || seq[1] != "alpha" { + t.Errorf("expected init order [beta, alpha], got %v", seq) + } +} + +func TestWithModuleDependency_DetectsCycle(t *testing.T) { + modA := &testDepModule{name: "alpha", initSeq: new([]string)} + modB := &testDepModule{name: "beta", initSeq: new([]string)} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + WithModuleDependency("alpha", "beta"), + WithModuleDependency("beta", "alpha"), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + err = app.Init() + if err == nil { + t.Fatal("expected circular dependency error") + } + if !IsErrCircularDependency(err) { + t.Errorf("expected ErrCircularDependency, got: %v", err) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithModuleDependency -count=1 -v` +Expected: FAIL — `WithModuleDependency` undefined + +**Step 3: Implement** + +In `builder.go`, add to `ApplicationBuilder`: +```go +dependencyHints []DependencyEdge +``` + +Add option function: +```go +// WithModuleDependency declares that module `from` depends on module `to`, +// injecting an edge into the dependency graph before resolution. +func WithModuleDependency(from, to string) Option { + return func(b *ApplicationBuilder) error { + b.dependencyHints = append(b.dependencyHints, DependencyEdge{ + From: from, + To: to, + Type: EdgeTypeModule, + }) + return nil + } +} +``` + +In `Build()`, after creating the app and before registering modules, store hints on the StdApplication. Add a new field to `StdApplication`: +```go +dependencyHints []DependencyEdge +``` + +In `Build()`, after `app` is created, set hints: +```go +if len(b.dependencyHints) > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dependencyHints = b.dependencyHints + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dependencyHints = b.dependencyHints + } +} +``` + +In `resolveDependencies()` in `application.go`, after building the graph from `DependencyAware` modules (around line 1104), add: +```go +// Merge config-driven dependency hints +for _, hint := range app.dependencyHints { + if graph[hint.From] == nil { + graph[hint.From] = nil + } + graph[hint.From] = append(graph[hint.From], hint.To) + dependencyEdges = append(dependencyEdges, hint) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithModuleDependency -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go builder_dependency_test.go +git commit -m "feat: add WithModuleDependency for config-driven dependency hints" +``` + +--- + +### Task 2: Drainable Interface (Shutdown Drain Phases) + +**Files:** +- Create: `drainable.go` — interface + drain timeout option +- Modify: `application.go` — call PreStop before Stop in `Stop()` +- Modify: `builder.go` — add `WithDrainTimeout` option +- Create: `drainable_test.go` — tests + +**Step 1: Write the failing test** + +Create `drainable_test.go`: + +```go +package modular + +import ( + "context" + "testing" + "time" +) + +type drainableModule struct { + name string + preStopSeq *[]string + stopSeq *[]string +} + +func (m *drainableModule) Name() string { return m.name } +func (m *drainableModule) Init(app Application) error { return nil } +func (m *drainableModule) Start(ctx context.Context) error { return nil } +func (m *drainableModule) PreStop(ctx context.Context) error { + *m.preStopSeq = append(*m.preStopSeq, m.name) + return nil +} +func (m *drainableModule) Stop(ctx context.Context) error { + *m.stopSeq = append(*m.stopSeq, m.name) + return nil +} + +func TestDrainable_PreStopCalledBeforeStop(t *testing.T) { + preStops := make([]string, 0) + stops := make([]string, 0) + + mod := &drainableModule{name: "drainer", preStopSeq: &preStops, stopSeq: &stops} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + + if len(preStops) != 1 || preStops[0] != "drainer" { + t.Errorf("expected PreStop called for drainer, got %v", preStops) + } + if len(stops) != 1 || stops[0] != "drainer" { + t.Errorf("expected Stop called for drainer, got %v", stops) + } +} + +func TestDrainable_WithDrainTimeout(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithDrainTimeout(5*time.Second), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp, ok := app.(*StdApplication) + if !ok { + t.Skip("not a StdApplication") + } + + if stdApp.drainTimeout != 5*time.Second { + t.Errorf("expected drain timeout 5s, got %v", stdApp.drainTimeout) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestDrainable -count=1 -v` +Expected: FAIL — `Drainable` undefined, `WithDrainTimeout` undefined + +**Step 3: Implement** + +Create `drainable.go`: +```go +package modular + +import ( + "context" + "time" +) + +// Drainable is an optional interface for modules that need a pre-stop drain phase. +// During shutdown, PreStop is called on all Drainable modules (reverse dependency order) +// before Stop is called on Stoppable modules. This allows modules to stop accepting +// new work and drain in-flight requests before the hard stop. +type Drainable interface { + // PreStop initiates graceful drain before stop. The context carries the drain timeout. + PreStop(ctx context.Context) error +} + +// defaultDrainTimeout is the default timeout for the PreStop drain phase. +const defaultDrainTimeout = 15 * time.Second +``` + +Add `drainTimeout` field to `StdApplication` in `application.go`: +```go +drainTimeout time.Duration +``` + +Add `WithDrainTimeout` option in `builder.go`: +```go +// WithDrainTimeout sets the timeout for the PreStop drain phase during shutdown. +func WithDrainTimeout(d time.Duration) Option { + return func(b *ApplicationBuilder) error { + b.drainTimeout = d + return nil + } +} +``` + +Add `drainTimeout time.Duration` to `ApplicationBuilder`. + +In `Build()`, propagate to StdApplication: +```go +if b.drainTimeout > 0 { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.drainTimeout = b.drainTimeout + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.drainTimeout = b.drainTimeout + } +} +``` + +Modify `Stop()` in `application.go` to call PreStop first: +```go +func (app *StdApplication) Stop() error { + modules, err := app.resolveDependencies() + if err != nil { + return err + } + slices.Reverse(modules) + + // Phase 1: Drain — call PreStop on all Drainable modules + drainTimeout := app.drainTimeout + if drainTimeout <= 0 { + drainTimeout = defaultDrainTimeout + } + drainCtx, drainCancel := context.WithTimeout(context.Background(), drainTimeout) + defer drainCancel() + + for _, name := range modules { + module := app.moduleRegistry[name] + drainableModule, ok := module.(Drainable) + if !ok { + continue + } + app.logger.Info("Draining module", "module", name) + if err := drainableModule.PreStop(drainCtx); err != nil { + app.logger.Error("Error draining module", "module", name, "error", err) + } + } + + // Phase 2: Stop — call Stop on all Stoppable modules + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var lastErr error + for _, name := range modules { + module := app.moduleRegistry[name] + stoppableModule, ok := module.(Stoppable) + if !ok { + app.logger.Debug("Module does not implement Stoppable, skipping", "module", name) + continue + } + app.logger.Info("Stopping module", "module", name) + if err = stoppableModule.Stop(ctx); err != nil { + app.logger.Error("Error stopping module", "module", name, "error", err) + lastErr = err + } + } + + if app.cancel != nil { + app.cancel() + } + return lastErr +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestDrainable -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add drainable.go drainable_test.go application.go builder.go +git commit -m "feat: add Drainable interface with PreStop drain phase" +``` + +--- + +### Task 3: Application Phase Tracking + +**Files:** +- Create: `phase.go` — AppPhase type, constants, String() +- Modify: `application.go` — add `phase` field, `Phase()` method, phase transitions +- Modify: `observer.go` — add `EventTypeAppPhaseChanged` constant +- Create: `phase_test.go` — tests + +**Step 1: Write the failing test** + +Create `phase_test.go`: + +```go +package modular + +import ( + "testing" +) + +func TestAppPhase_String(t *testing.T) { + tests := []struct { + phase AppPhase + want string + }{ + {PhaseCreated, "created"}, + {PhaseInitializing, "initializing"}, + {PhaseStarting, "starting"}, + {PhaseRunning, "running"}, + {PhaseDraining, "draining"}, + {PhaseStopping, "stopping"}, + {PhaseStopped, "stopped"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("AppPhase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestPhaseTracking_LifecycleTransitions(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + + if stdApp.Phase() != PhaseCreated { + t.Errorf("expected PhaseCreated, got %v", stdApp.Phase()) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + // After Init, phase should be past initializing (at least initialized) + phase := stdApp.Phase() + if phase != PhaseInitialized { + t.Errorf("expected PhaseInitialized after Init, got %v", phase) + } + + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if stdApp.Phase() != PhaseRunning { + t.Errorf("expected PhaseRunning after Start, got %v", stdApp.Phase()) + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if stdApp.Phase() != PhaseStopped { + t.Errorf("expected PhaseStopped after Stop, got %v", stdApp.Phase()) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestAppPhase -count=1 -v && go test -run TestPhaseTracking -count=1 -v` +Expected: FAIL — `AppPhase` undefined + +**Step 3: Implement** + +Create `phase.go`: +```go +package modular + +import "sync/atomic" + +// AppPhase represents the current lifecycle phase of the application. +type AppPhase int32 + +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseInitialized + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) + +func (p AppPhase) String() string { + switch p { + case PhaseCreated: + return "created" + case PhaseInitializing: + return "initializing" + case PhaseInitialized: + return "initialized" + case PhaseStarting: + return "starting" + case PhaseRunning: + return "running" + case PhaseDraining: + return "draining" + case PhaseStopping: + return "stopping" + case PhaseStopped: + return "stopped" + default: + return "unknown" + } +} +``` + +Add `phase atomic.Int32` field to `StdApplication`. Add `Phase()` method: +```go +func (app *StdApplication) Phase() AppPhase { + return AppPhase(app.phase.Load()) +} + +func (app *StdApplication) setPhase(p AppPhase) { + app.phase.Store(int32(p)) +} +``` + +Add `EventTypeAppPhaseChanged` to `observer.go`: +```go +EventTypeAppPhaseChanged = "com.modular.application.phase.changed" +``` + +In `InitWithApp()`, wrap with phase transitions: +```go +app.setPhase(PhaseInitializing) +// ... existing init logic ... +app.setPhase(PhaseInitialized) +``` + +In `Start()`: +```go +app.setPhase(PhaseStarting) +// ... existing start logic ... +app.setPhase(PhaseRunning) +``` + +In `Stop()`: +```go +app.setPhase(PhaseDraining) +// ... PreStop phase ... +app.setPhase(PhaseStopping) +// ... Stop phase ... +app.setPhase(PhaseStopped) +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run "TestAppPhase|TestPhaseTracking" -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add phase.go phase_test.go application.go observer.go +git commit -m "feat: add application phase tracking with lifecycle transitions" +``` + +--- + +### Task 4: Parallel Init at Same Topological Depth + +**Files:** +- Modify: `builder.go` — add `WithParallelInit` option +- Modify: `application.go` — parallel init logic using `errgroup` +- Create: `parallel_init_test.go` — tests + +**Step 1: Write the failing test** + +Create `parallel_init_test.go`: + +```go +package modular + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +type parallelInitModule struct { + name string + deps []string + initDelay time.Duration + initCount *atomic.Int32 + maxPar *atomic.Int32 + curPar *atomic.Int32 +} + +func (m *parallelInitModule) Name() string { return m.name } +func (m *parallelInitModule) Dependencies() []string { return m.deps } +func (m *parallelInitModule) Init(app Application) error { + cur := m.curPar.Add(1) + defer m.curPar.Add(-1) + + // Track max concurrency + for { + old := m.maxPar.Load() + if cur <= old || m.maxPar.CompareAndSwap(old, cur) { + break + } + } + + m.initCount.Add(1) + time.Sleep(m.initDelay) + return nil +} + +func TestWithParallelInit_ConcurrentSameDepth(t *testing.T) { + var initCount, maxPar, curPar atomic.Int32 + + // Three independent modules (no deps) — should init concurrently + modA := ¶llelInitModule{name: "a", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modB := ¶llelInitModule{name: "b", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modC := ¶llelInitModule{name: "c", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB, modC), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + start := time.Now() + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + elapsed := time.Since(start) + + if initCount.Load() != 3 { + t.Errorf("expected 3 inits, got %d", initCount.Load()) + } + if maxPar.Load() < 2 { + t.Errorf("expected at least 2 concurrent inits, got max %d", maxPar.Load()) + } + // Should complete faster than 3 * 50ms sequential + if elapsed > 120*time.Millisecond { + t.Errorf("expected parallel init to be faster, took %v", elapsed) + } +} + +func TestWithParallelInit_RespectsDepOrder(t *testing.T) { + var mu sync.Mutex + order := make([]string, 0) + + makeModule := func(name string, deps []string) *simpleOrderModule { + return &simpleOrderModule{name: name, deps: deps, order: &order, mu: &mu} + } + + // dep → a, dep → b (a and b can be parallel, dep must be first) + modDep := makeModule("dep", nil) + modA := makeModule("a", []string{"dep"}) + modB := makeModule("b", []string{"dep"}) + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modDep, modA, modB), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(order) != 3 || order[0] != "dep" { + t.Errorf("expected dep first, got order %v", order) + } +} + +type simpleOrderModule struct { + name string + deps []string + order *[]string + mu *sync.Mutex +} + +func (m *simpleOrderModule) Name() string { return m.name } +func (m *simpleOrderModule) Dependencies() []string { return m.deps } +func (m *simpleOrderModule) Init(app Application) error { + m.mu.Lock() + *m.order = append(*m.order, m.name) + m.mu.Unlock() + return nil +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithParallelInit -count=1 -v` +Expected: FAIL — `WithParallelInit` undefined + +**Step 3: Implement** + +Add `parallelInit bool` field to `ApplicationBuilder` and `StdApplication`. + +Add builder option: +```go +// WithParallelInit enables concurrent initialization of modules at the same +// topological depth in the dependency graph. Disabled by default. +func WithParallelInit() Option { + return func(b *ApplicationBuilder) error { + b.parallelInit = true + return nil + } +} +``` + +Propagate in `Build()` similar to other fields. + +In `application.go`, add a method to compute topological depth levels: +```go +// computeDepthLevels groups modules by their topological depth. +// Level 0 has no dependencies, level 1 depends only on level 0, etc. +func (app *StdApplication) computeDepthLevels(order []string) [][]string { + depth := make(map[string]int) + graph := make(map[string][]string) + + // Rebuild graph for depth calculation + for _, name := range order { + module := app.moduleRegistry[name] + if depAware, ok := module.(DependencyAware); ok { + graph[name] = depAware.Dependencies() + } + // Include config-driven hints + for _, hint := range app.dependencyHints { + if hint.From == name { + graph[name] = append(graph[name], hint.To) + } + } + } + + // Compute depths + var computeDepth func(string) int + computeDepth = func(name string) int { + if d, ok := depth[name]; ok { + return d + } + maxDep := 0 + for _, dep := range graph[name] { + if d := computeDepth(dep) + 1; d > maxDep { + maxDep = d + } + } + depth[name] = maxDep + return maxDep + } + + for _, name := range order { + computeDepth(name) + } + + // Group by depth + maxDepth := 0 + for _, d := range depth { + if d > maxDepth { + maxDepth = d + } + } + + levels := make([][]string, maxDepth+1) + for _, name := range order { + d := depth[name] + levels[d] = append(levels[d], name) + } + return levels +} +``` + +Modify `InitWithApp` to use parallel init when enabled. Replace the sequential init loop with: +```go +if app.parallelInit { + levels := app.computeDepthLevels(moduleOrder) + for _, level := range levels { + if len(level) == 1 { + // Single module — init sequentially (no goroutine overhead) + if err := app.initModule(appToPass, level[0]); err != nil { + errs = append(errs, err) + } + } else { + // Multiple modules at same depth — init concurrently + var levelErrs []error + var mu sync.Mutex + var wg sync.WaitGroup + for _, moduleName := range level { + wg.Add(1) + go func(name string) { + defer wg.Done() + if err := app.initModule(appToPass, name); err != nil { + mu.Lock() + levelErrs = append(levelErrs, err) + mu.Unlock() + } + }(moduleName) + } + wg.Wait() + errs = append(errs, levelErrs...) + } + } +} else { + // Sequential init (existing behavior) + for _, moduleName := range moduleOrder { + if err := app.initModule(appToPass, moduleName); err != nil { + errs = append(errs, err) + } + } +} +``` + +Extract the per-module init logic into a helper: +```go +func (app *StdApplication) initModule(appToPass Application, moduleName string) error { + var err error + module := app.moduleRegistry[moduleName] + + if _, ok := module.(ServiceAware); ok { + app.moduleRegistry[moduleName], err = app.injectServices(module) + if err != nil { + return fmt.Errorf("failed to inject services for module '%s': %w", moduleName, err) + } + module = app.moduleRegistry[moduleName] + } + + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.SetCurrentModule(module) + } + + if err = module.Init(appToPass); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to initialize: %w", moduleName, err) + } + + if svcAware, ok := module.(ServiceAware); ok { + for _, svc := range svcAware.ProvidesServices() { + if err = app.RegisterService(svc.Name, svc.Instance); err != nil { + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + return fmt.Errorf("module '%s' failed to register service '%s': %w", moduleName, svc.Name, err) + } + } + } + + if app.enhancedSvcRegistry != nil { + app.enhancedSvcRegistry.ClearCurrentModule() + } + + app.logger.Info(fmt.Sprintf("Initialized module %s of type %T", moduleName, app.moduleRegistry[moduleName])) + return nil +} +``` + +**Note:** When parallel init is enabled, `SetCurrentModule`/`ClearCurrentModule` need mutex protection. Add a mutex to the init path or guard the enhanced registry calls. + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithParallelInit -count=1 -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /tmp/gca-modular && go test ./... -count=1` +Expected: All existing tests pass + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go parallel_init_test.go +git commit -m "feat: add WithParallelInit for concurrent module initialization" +``` + +--- + +### Task 5: Type-Safe Service Helpers (Generics) + +**Files:** +- Create: `service_typed.go` — generic helper functions +- Create: `service_typed_test.go` — tests + +**Step 1: Write the failing test** + +Create `service_typed_test.go`: + +```go +package modular + +import ( + "testing" +) + +type testService struct { + Value string +} + +func TestRegisterTypedService_and_GetTypedService(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + + svc := &testService{Value: "hello"} + if err := RegisterTypedService[*testService](app, "test.svc", svc); err != nil { + t.Fatalf("RegisterTypedService: %v", err) + } + + got, err := GetTypedService[*testService](app, "test.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if got.Value != "hello" { + t.Errorf("expected hello, got %s", got.Value) + } +} + +func TestGetTypedService_WrongType(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _ = RegisterTypedService[string](app, "str.svc", "hello") + + _, err := GetTypedService[int](app, "str.svc") + if err == nil { + t.Fatal("expected type mismatch error") + } +} + +func TestGetTypedService_NotFound(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + + _, err := GetTypedService[string](app, "missing") + if err == nil { + t.Fatal("expected not found error") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestRegisterTypedService -count=1 -v && go test -run TestGetTypedService -count=1 -v` +Expected: FAIL — `RegisterTypedService` undefined + +**Step 3: Implement** + +Create `service_typed.go`: + +```go +package modular + +import "fmt" + +// RegisterTypedService registers a service with compile-time type safety. +// This is a package-level helper that wraps Application.RegisterService. +func RegisterTypedService[T any](app Application, name string, svc T) error { + return app.RegisterService(name, svc) +} + +// GetTypedService retrieves a service with compile-time type safety. +// Returns the zero value of T and an error if the service is not found +// or cannot be cast to the expected type. +func GetTypedService[T any](app Application, name string) (T, error) { + var zero T + svcRegistry := app.SvcRegistry() + raw, exists := svcRegistry[name] + if !exists { + return zero, fmt.Errorf("%w: %s", ErrServiceNotFound, name) + } + typed, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: service %q is %T, want %T", ErrServiceWrongType, name, raw, zero) + } + return typed, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run "TestRegisterTypedService|TestGetTypedService" -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add service_typed.go service_typed_test.go +git commit -m "feat: add RegisterTypedService/GetTypedService generic helpers" +``` + +--- + +### Task 6: Service Readiness Events & OnServiceReady + +**Files:** +- Modify: `service.go` — add `OnServiceReady` method to `EnhancedServiceRegistry` +- Create: `service_readiness_test.go` — tests + +**Step 1: Write the failing test** + +Create `service_readiness_test.go`: + +```go +package modular + +import ( + "sync/atomic" + "testing" +) + +func TestOnServiceReady_AlreadyRegistered(t *testing.T) { + registry := NewEnhancedServiceRegistry() + registry.RegisterService("db", "postgres-conn") + + var called atomic.Bool + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + if svc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", svc) + } + }) + + if !called.Load() { + t.Error("callback should have been called immediately") + } +} + +func TestOnServiceReady_DeferredUntilRegistration(t *testing.T) { + registry := NewEnhancedServiceRegistry() + + var called atomic.Bool + var receivedSvc any + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + receivedSvc = svc + }) + + if called.Load() { + t.Error("callback should not have been called yet") + } + + registry.RegisterService("db", "postgres-conn") + + if !called.Load() { + t.Error("callback should have been called after registration") + } + if receivedSvc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", receivedSvc) + } +} + +func TestOnServiceReady_MultipleCallbacks(t *testing.T) { + registry := NewEnhancedServiceRegistry() + + var count atomic.Int32 + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + + registry.RegisterService("cache", "redis") + + if count.Load() != 2 { + t.Errorf("expected 2 callbacks, got %d", count.Load()) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestOnServiceReady -count=1 -v` +Expected: FAIL — `OnServiceReady` undefined + +**Step 3: Implement** + +Add to `EnhancedServiceRegistry`: +```go +// readyCallbacks maps service names to pending callbacks. +readyCallbacks map[string][]func(any) +``` + +Initialize in `NewEnhancedServiceRegistry`: +```go +readyCallbacks: make(map[string][]func(any)), +``` + +Add the method: +```go +// OnServiceReady registers a callback that fires when the named service is registered. +// If the service is already registered, the callback fires immediately. +func (r *EnhancedServiceRegistry) OnServiceReady(name string, callback func(any)) { + if entry, exists := r.services[name]; exists { + callback(entry.Service) + return + } + r.readyCallbacks[name] = append(r.readyCallbacks[name], callback) +} +``` + +Modify `RegisterService` to fire pending callbacks after registration: +```go +// After r.services[actualName] = entry, add: +// Fire readiness callbacks for the original name and the actual name. +for _, cbName := range []string{originalName, actualName} { + if callbacks, ok := r.readyCallbacks[cbName]; ok { + for _, cb := range callbacks { + cb(service) + } + delete(r.readyCallbacks, cbName) + } +} +``` + +Note: Use `originalName` as the variable name for the first parameter to `RegisterService` (it's called `name` in the current code — rename to `originalName` for clarity, or just use `name`). + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestOnServiceReady -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add service.go service_readiness_test.go +git commit -m "feat: add OnServiceReady callback for service readiness events" +``` + +--- + +### Task 7: Plugin Interface & WithPlugins + +**Files:** +- Create: `plugin.go` — Plugin, PluginWithHooks, PluginWithServices interfaces + ServiceDefinition +- Modify: `builder.go` — add `WithPlugins` option +- Create: `plugin_test.go` — tests + +**Step 1: Write the failing test** + +Create `plugin_test.go`: + +```go +package modular + +import ( + "testing" +) + +type testPlugin struct { + modules []Module + services []ServiceDefinition + hookRan bool +} + +func (p *testPlugin) Name() string { return "test-plugin" } +func (p *testPlugin) Modules() []Module { return p.modules } +func (p *testPlugin) Services() []ServiceDefinition { return p.services } +func (p *testPlugin) InitHooks() []func(Application) error { + return []func(Application) error{ + func(app Application) error { + p.hookRan = true + return nil + }, + } +} + +type pluginModule struct { + name string + initialized bool +} + +func (m *pluginModule) Name() string { return m.name } +func (m *pluginModule) Init(app Application) error { m.initialized = true; return nil } + +func TestWithPlugins_RegistersModulesAndServices(t *testing.T) { + mod := &pluginModule{name: "plugin-mod"} + plugin := &testPlugin{ + modules: []Module{mod}, + services: []ServiceDefinition{{Name: "plugin.svc", Service: "hello"}}, + } + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } + if !plugin.hookRan { + t.Error("plugin hook should have run") + } + + svc, err := GetTypedService[string](app, "plugin.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if svc != "hello" { + t.Errorf("expected hello, got %s", svc) + } +} + +// Test a simple plugin (no hooks, no services) +type simplePlugin struct { + modules []Module +} + +func (p *simplePlugin) Name() string { return "simple" } +func (p *simplePlugin) Modules() []Module { return p.modules } + +func TestWithPlugins_SimplePlugin(t *testing.T) { + mod := &pluginModule{name: "simple-mod"} + plugin := &simplePlugin{modules: []Module{mod}} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithPlugins -count=1 -v` +Expected: FAIL — `Plugin` undefined + +**Step 3: Implement** + +Create `plugin.go`: +```go +package modular + +// Plugin is the minimal interface for a plugin bundle that provides modules. +type Plugin interface { + Name() string + Modules() []Module +} + +// PluginWithHooks extends Plugin with initialization hooks. +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +// PluginWithServices extends Plugin with service definitions. +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +// ServiceDefinition describes a service provided by a plugin. +type ServiceDefinition struct { + Name string + Service any +} +``` + +Add `plugins []Plugin` to `ApplicationBuilder`. Add option: +```go +// WithPlugins registers plugins with the application. Each plugin's modules +// are registered, hooks are added as config-loaded hooks, and services are +// registered before module init. +func WithPlugins(plugins ...Plugin) Option { + return func(b *ApplicationBuilder) error { + b.plugins = append(b.plugins, plugins...) + return nil + } +} +``` + +In `Build()`, after creating the app, process plugins: +```go +for _, plugin := range b.plugins { + // Register plugin modules + for _, mod := range plugin.Modules() { + app.RegisterModule(mod) + } + + // Register plugin services + if withSvc, ok := plugin.(PluginWithServices); ok { + for _, svcDef := range withSvc.Services() { + if err := app.RegisterService(svcDef.Name, svcDef.Service); err != nil { + return nil, fmt.Errorf("plugin %q service %q: %w", plugin.Name(), svcDef.Name, err) + } + } + } + + // Register plugin hooks + if withHooks, ok := plugin.(PluginWithHooks); ok { + for _, hook := range withHooks.InitHooks() { + app.OnConfigLoaded(hook) + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithPlugins -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add plugin.go plugin_test.go builder.go +git commit -m "feat: add Plugin interface with WithPlugins builder option" +``` + +--- + +### Task 8: ReloadOrchestrator Integration (`WithDynamicReload`) + +**Files:** +- Modify: `builder.go` — add `WithDynamicReload` option +- Modify: `application.go` — wire orchestrator into Start/Stop, expose `RequestReload` +- Create: `reload_integration_test.go` — tests + +**Step 1: Write the failing test** + +Create `reload_integration_test.go`: + +```go +package modular + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type reloadableTestModule struct { + name string + reloadCount atomic.Int32 +} + +func (m *reloadableTestModule) Name() string { return m.name } +func (m *reloadableTestModule) Init(app Application) error { return nil } +func (m *reloadableTestModule) CanReload() bool { return true } +func (m *reloadableTestModule) ReloadTimeout() time.Duration { return 5 * time.Second } +func (m *reloadableTestModule) Reload(ctx context.Context, changes []ConfigChange) error { + m.reloadCount.Add(1) + return nil +} + +func TestWithDynamicReload_WiresOrchestrator(t *testing.T) { + mod := &reloadableTestModule{name: "hot-mod"} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + WithDynamicReload(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + stdApp := app.(*StdApplication) + + // Request a reload + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key": {OldValue: "old", NewValue: "new", FieldPath: "key", ChangeType: ChangeModified}, + }, + DiffID: "test-diff", + } + err = stdApp.RequestReload(context.Background(), ReloadManual, diff) + if err != nil { + t.Fatalf("RequestReload: %v", err) + } + + // Wait for reload to process + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mod.reloadCount.Load() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if mod.reloadCount.Load() == 0 { + t.Error("expected module to be reloaded") + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestWithDynamicReload -count=1 -v` +Expected: FAIL — `WithDynamicReload` undefined + +**Step 3: Implement** + +Add `dynamicReload bool` to `ApplicationBuilder`. +Add `reloadOrchestrator *ReloadOrchestrator` to `StdApplication`. + +Builder option: +```go +// WithDynamicReload enables the ReloadOrchestrator, wiring it into the +// application lifecycle. Reloadable modules are auto-registered after Init, +// and the orchestrator starts/stops with the application. +func WithDynamicReload() Option { + return func(b *ApplicationBuilder) error { + b.dynamicReload = true + return nil + } +} +``` + +In `Build()`, propagate: +```go +if b.dynamicReload { + if stdApp, ok := app.(*StdApplication); ok { + stdApp.dynamicReload = true + } else if obsApp, ok := app.(*ObservableApplication); ok { + obsApp.dynamicReload = true + } +} +``` + +Add `dynamicReload bool` field to `StdApplication`. + +In `InitWithApp`, after all modules are initialized (before marking initialized), register reloadables: +```go +if app.dynamicReload { + var subject Subject + if obsApp, ok := appToPass.(*ObservableApplication); ok { + subject = obsApp + } + app.reloadOrchestrator = NewReloadOrchestrator(app.logger, subject) + for name, module := range app.moduleRegistry { + if reloadable, ok := module.(Reloadable); ok { + app.reloadOrchestrator.RegisterReloadable(name, reloadable) + } + } +} +``` + +In `Start()`, after starting all modules: +```go +if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Start(ctx) +} +``` + +In `Stop()`, before draining: +```go +if app.reloadOrchestrator != nil { + app.reloadOrchestrator.Stop() +} +``` + +Add `RequestReload` method: +```go +// RequestReload enqueues a reload request. Only available when WithDynamicReload is enabled. +func (app *StdApplication) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) error { + if app.reloadOrchestrator == nil { + return fmt.Errorf("dynamic reload not enabled") + } + return app.reloadOrchestrator.RequestReload(ctx, trigger, diff) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestWithDynamicReload -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add builder.go application.go reload_integration_test.go +git commit -m "feat: add WithDynamicReload to wire ReloadOrchestrator into app lifecycle" +``` + +--- + +### Task 9: Secret Resolution Hooks + +**Files:** +- Create: `secret_resolver.go` — SecretResolver interface + ExpandSecrets utility +- Create: `secret_resolver_test.go` — tests + +**Step 1: Write the failing test** + +Create `secret_resolver_test.go`: + +```go +package modular + +import ( + "context" + "strings" + "testing" +) + +type mockResolver struct { + prefix string + values map[string]string +} + +func (r *mockResolver) CanResolve(ref string) bool { + return strings.HasPrefix(ref, r.prefix+":") +} + +func (r *mockResolver) ResolveSecret(ctx context.Context, ref string) (string, error) { + key := strings.TrimPrefix(ref, r.prefix+":") + if v, ok := r.values[key]; ok { + return v, nil + } + return "", ErrServiceNotFound +} + +func TestExpandSecrets_ResolvesRefs(t *testing.T) { + resolver := &mockResolver{ + prefix: "vault", + values: map[string]string{ + "secret/db-pass": "s3cret", + }, + } + + config := map[string]any{ + "host": "localhost", + "password": "${vault:secret/db-pass}", + "nested": map[string]any{ + "key": "${vault:secret/db-pass}", + }, + } + + err := ExpandSecrets(context.Background(), config, resolver) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + + if config["password"] != "s3cret" { + t.Errorf("expected s3cret, got %v", config["password"]) + } + nested := config["nested"].(map[string]any) + if nested["key"] != "s3cret" { + t.Errorf("expected nested s3cret, got %v", nested["key"]) + } +} + +func TestExpandSecrets_SkipsNonRefs(t *testing.T) { + config := map[string]any{ + "host": "localhost", + "port": 5432, + } + + err := ExpandSecrets(context.Background(), config) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + + if config["host"] != "localhost" { + t.Errorf("expected localhost, got %v", config["host"]) + } +} + +func TestExpandSecrets_NoMatchingResolver(t *testing.T) { + config := map[string]any{ + "password": "${aws:secret/key}", + } + + resolver := &mockResolver{prefix: "vault", values: map[string]string{}} + + // No matching resolver — value should remain unchanged + err := ExpandSecrets(context.Background(), config, resolver) + if err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "${aws:secret/key}" { + t.Errorf("expected unchanged ref, got %v", config["password"]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestExpandSecrets -count=1 -v` +Expected: FAIL — `SecretResolver` undefined + +**Step 3: Implement** + +Create `secret_resolver.go`: + +```go +package modular + +import ( + "context" + "fmt" + "regexp" +) + +// SecretResolver resolves secret references in configuration values. +// Implementations connect to secret stores (Vault, AWS Secrets Manager, etc.) +type SecretResolver interface { + // ResolveSecret resolves a secret reference string to its actual value. + ResolveSecret(ctx context.Context, ref string) (string, error) + + // CanResolve reports whether this resolver handles the given reference. + CanResolve(ref string) bool +} + +// secretRefPattern matches ${prefix:path} patterns in config values. +var secretRefPattern = regexp.MustCompile(`^\$\{([^:}]+:[^}]+)\}$`) + +// ExpandSecrets walks a config map and replaces string values matching +// ${prefix:path} with the resolved secret value. It recurses into nested +// maps. Values that don't match or have no matching resolver are left unchanged. +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error { + for key, val := range config { + switch v := val.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + config[key] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + } + } + return nil +} + +func resolveSecretString(ctx context.Context, val string, resolvers []SecretResolver) (string, error) { + match := secretRefPattern.FindStringSubmatch(val) + if match == nil { + return val, nil + } + ref := match[1] + for _, r := range resolvers { + if r.CanResolve(ref) { + return r.ResolveSecret(ctx, ref) + } + } + // No matching resolver — return unchanged + return val, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestExpandSecrets -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add secret_resolver.go secret_resolver_test.go +git commit -m "feat: add SecretResolver interface and ExpandSecrets utility" +``` + +--- + +### Task 10: Config File Watcher Module + +**Files:** +- Create: `modules/configwatcher/configwatcher.go` — module implementation +- Create: `modules/configwatcher/configwatcher_test.go` — tests +- Modify: `go.mod` — add `github.com/fsnotify/fsnotify` dependency + +**Step 1: Add fsnotify dependency** + +Run: `cd /tmp/gca-modular && go get github.com/fsnotify/fsnotify` + +**Step 2: Write the test** + +Create `modules/configwatcher/configwatcher_test.go`: + +```go +package configwatcher + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestConfigWatcher_DetectsFileChange(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("key: value1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(50*time.Millisecond), + WithOnChange(func(paths []string) { + changeCount.Add(1) + }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + // Modify the file + time.Sleep(100 * time.Millisecond) + if err := os.WriteFile(cfgFile, []byte("key: value2"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for debounce + processing + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if changeCount.Load() > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + + if changeCount.Load() == 0 { + t.Error("expected at least one change notification") + } +} + +func TestConfigWatcher_Debounces(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("v1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(200*time.Millisecond), + WithOnChange(func(paths []string) { + changeCount.Add(1) + }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + // Rapid-fire writes + for i := 0; i < 5; i++ { + os.WriteFile(cfgFile, []byte("v"+string(rune('2'+i))), 0644) + time.Sleep(20 * time.Millisecond) + } + + // Wait for debounce + time.Sleep(500 * time.Millisecond) + + if changeCount.Load() > 2 { + t.Errorf("expected debounced to ~1-2 calls, got %d", changeCount.Load()) + } +} +``` + +**Step 3: Implement** + +Create `modules/configwatcher/configwatcher.go`: + +```go +// Package configwatcher provides a module that watches configuration files +// for changes and triggers reload via a callback. +package configwatcher + +import ( + "context" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// ConfigWatcher watches config files and calls OnChange when modifications are detected. +type ConfigWatcher struct { + paths []string + debounce time.Duration + onChange func(paths []string) + watcher *fsnotify.Watcher + stopCh chan struct{} + stopOnce sync.Once +} + +// Option configures a ConfigWatcher. +type Option func(*ConfigWatcher) + +// WithPaths sets the file paths to watch. +func WithPaths(paths ...string) Option { + return func(w *ConfigWatcher) { + w.paths = append(w.paths, paths...) + } +} + +// WithDebounce sets the debounce duration for file change events. +func WithDebounce(d time.Duration) Option { + return func(w *ConfigWatcher) { + w.debounce = d + } +} + +// WithOnChange sets the callback invoked when watched files change. +func WithOnChange(fn func(paths []string)) Option { + return func(w *ConfigWatcher) { + w.onChange = fn + } +} + +// New creates a new ConfigWatcher with the given options. +func New(opts ...Option) *ConfigWatcher { + w := &ConfigWatcher{ + debounce: 500 * time.Millisecond, + stopCh: make(chan struct{}), + } + for _, opt := range opts { + opt(w) + } + return w +} + +// Name returns the module name. +func (w *ConfigWatcher) Name() string { return "configwatcher" } + +// Init is a no-op for the config watcher module. +func (w *ConfigWatcher) Init(_ interface{ Logger() interface{ Info(string, ...any) } }) error { + return nil +} + +// Start begins watching the configured paths. +func (w *ConfigWatcher) Start(ctx context.Context) error { + if err := w.startWatching(); err != nil { + return err + } + go func() { + select { + case <-ctx.Done(): + w.stopWatching() + case <-w.stopCh: + } + }() + return nil +} + +// Stop stops the file watcher. +func (w *ConfigWatcher) Stop(_ context.Context) error { + w.stopWatching() + return nil +} + +func (w *ConfigWatcher) startWatching() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + w.watcher = watcher + + for _, path := range w.paths { + if err := watcher.Add(path); err != nil { + watcher.Close() + return err + } + } + + go w.eventLoop() + return nil +} + +func (w *ConfigWatcher) stopWatching() { + w.stopOnce.Do(func() { + close(w.stopCh) + if w.watcher != nil { + w.watcher.Close() + } + }) +} + +func (w *ConfigWatcher) eventLoop() { + var timer *time.Timer + changedPaths := make(map[string]struct{}) + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + changedPaths[event.Name] = struct{}{} + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(w.debounce, func() { + if w.onChange != nil { + paths := make([]string, 0, len(changedPaths)) + for p := range changedPaths { + paths = append(paths, p) + } + changedPaths = make(map[string]struct{}) + w.onChange(paths) + } + }) + } + case _, ok := <-w.watcher.Errors: + if !ok { + return + } + case <-w.stopCh: + if timer != nil { + timer.Stop() + } + return + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test ./modules/configwatcher/... -count=1 -v` +Expected: PASS + +**Step 5: Run `go mod tidy`** + +Run: `cd /tmp/gca-modular && go mod tidy` + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add modules/configwatcher/ go.mod go.sum +git commit -m "feat: add configwatcher module with fsnotify file watching" +``` + +--- + +### Task 11: Slog Adapter + +**Files:** +- Create: `slog_adapter.go` — SlogAdapter implementation +- Create: `slog_adapter_test.go` — tests + +**Step 1: Write the failing test** + +Create `slog_adapter_test.go`: + +```go +package modular + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestSlogAdapter_ImplementsLogger(t *testing.T) { + var _ Logger = (*SlogAdapter)(nil) // compile-time check +} + +func TestSlogAdapter_DelegatesToSlog(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger) + adapter.Info("test info", "key", "value") + adapter.Error("test error", "err", "fail") + adapter.Warn("test warn") + adapter.Debug("test debug") + + output := buf.String() + if !strings.Contains(output, "test info") { + t.Error("expected info message in output") + } + if !strings.Contains(output, "test error") { + t.Error("expected error message in output") + } + if !strings.Contains(output, "test warn") { + t.Error("expected warn message in output") + } + if !strings.Contains(output, "test debug") { + t.Error("expected debug message in output") + } +} + +func TestSlogAdapter_With(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger).With("module", "test") + adapter.Info("with test") + + output := buf.String() + if !strings.Contains(output, "module=test") { + t.Errorf("expected module=test in output, got: %s", output) + } +} + +func TestSlogAdapter_WithGroup(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + + adapter := NewSlogAdapter(logger).WithGroup("mygroup") + adapter.Info("group test", "key", "val") + + output := buf.String() + if !strings.Contains(output, "mygroup") { + t.Errorf("expected mygroup in output, got: %s", output) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestSlogAdapter -count=1 -v` +Expected: FAIL — `SlogAdapter` undefined + +**Step 3: Implement** + +Create `slog_adapter.go`: + +```go +package modular + +import "log/slog" + +// SlogAdapter wraps a *slog.Logger to implement the Logger interface. +// This allows using Go's standard structured logger with the modular framework. +type SlogAdapter struct { + logger *slog.Logger +} + +// NewSlogAdapter creates a new SlogAdapter wrapping the given slog.Logger. +func NewSlogAdapter(l *slog.Logger) *SlogAdapter { + return &SlogAdapter{logger: l} +} + +// Info logs at info level. +func (a *SlogAdapter) Info(msg string, args ...any) { a.logger.Info(msg, args...) } + +// Error logs at error level. +func (a *SlogAdapter) Error(msg string, args ...any) { a.logger.Error(msg, args...) } + +// Warn logs at warn level. +func (a *SlogAdapter) Warn(msg string, args ...any) { a.logger.Warn(msg, args...) } + +// Debug logs at debug level. +func (a *SlogAdapter) Debug(msg string, args ...any) { a.logger.Debug(msg, args...) } + +// With returns a new SlogAdapter with the given key-value pairs added to the context. +func (a *SlogAdapter) With(args ...any) *SlogAdapter { + return &SlogAdapter{logger: a.logger.With(args...)} +} + +// WithGroup returns a new SlogAdapter with the given group name. +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter { + return &SlogAdapter{logger: a.logger.WithGroup(name)} +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestSlogAdapter -count=1 -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /tmp/gca-modular +git add slog_adapter.go slog_adapter_test.go +git commit -m "feat: add SlogAdapter wrapping *slog.Logger for Logger interface" +``` + +--- + +### Task 12: Module Metrics Hooks + +**Files:** +- Create: `metrics.go` — MetricsProvider interface, ModuleMetrics type, CollectAllMetrics +- Modify: `application.go` — add `CollectAllMetrics` method +- Create: `metrics_test.go` — tests + +**Step 1: Write the failing test** + +Create `metrics_test.go`: + +```go +package modular + +import ( + "context" + "testing" +) + +type metricsModule struct { + name string +} + +func (m *metricsModule) Name() string { return m.name } +func (m *metricsModule) Init(app Application) error { return nil } +func (m *metricsModule) CollectMetrics(ctx context.Context) ModuleMetrics { + return ModuleMetrics{ + Name: m.name, + Values: map[string]float64{ + "requests_total": 100, + "error_rate": 0.02, + }, + } +} + +func TestCollectAllMetrics(t *testing.T) { + modA := &metricsModule{name: "api"} + modB := &pluginModule{name: "no-metrics"} // doesn't implement MetricsProvider + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + + if len(metrics) != 1 { + t.Fatalf("expected 1 metrics result, got %d", len(metrics)) + } + if metrics[0].Name != "api" { + t.Errorf("expected api, got %s", metrics[0].Name) + } + if metrics[0].Values["requests_total"] != 100 { + t.Errorf("expected requests_total=100, got %v", metrics[0].Values["requests_total"]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /tmp/gca-modular && go test -run TestCollectAllMetrics -count=1 -v` +Expected: FAIL — `MetricsProvider` undefined + +**Step 3: Implement** + +Create `metrics.go`: + +```go +package modular + +import "context" + +// ModuleMetrics holds metrics collected from a single module. +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +// MetricsProvider is an optional interface for modules that expose operational metrics. +// The framework collects metrics from all implementing modules on demand. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} +``` + +Add method to `application.go`: + +```go +// CollectAllMetrics gathers metrics from all modules implementing MetricsProvider. +func (app *StdApplication) CollectAllMetrics(ctx context.Context) []ModuleMetrics { + var results []ModuleMetrics + for _, module := range app.moduleRegistry { + if mp, ok := module.(MetricsProvider); ok { + results = append(results, mp.CollectMetrics(ctx)) + } + } + return results +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /tmp/gca-modular && go test -run TestCollectAllMetrics -count=1 -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd /tmp/gca-modular && go test ./... -count=1` +Expected: All tests pass + +**Step 6: Commit** + +```bash +cd /tmp/gca-modular +git add metrics.go metrics_test.go application.go +git commit -m "feat: add MetricsProvider interface and CollectAllMetrics" +``` + +--- + +## Post-Implementation + +After all 12 tasks are complete: + +1. Run full test suite: `cd /tmp/gca-modular && go test ./... -count=1 -race` +2. Run linter: `cd /tmp/gca-modular && golangci-lint run` +3. Run vet: `cd /tmp/gca-modular && go vet ./...` +4. Fix any issues found + +All work is on the `feat/reimplementation` branch. Create a PR against `main` when complete. diff --git a/docs/plans/aggregate-health.md b/docs/plans/aggregate-health.md new file mode 100644 index 00000000..7f3ae47b --- /dev/null +++ b/docs/plans/aggregate-health.md @@ -0,0 +1,107 @@ +# Aggregate Health Service — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists (~15%):** +- ReverseProxy `HealthChecker` with concurrent backend checks, events, debug endpoints (`modules/reverseproxy/health_checker.go`) +- Backend health events: `EventTypeBackendHealthy`, `EventTypeBackendUnhealthy` (`modules/reverseproxy/events.go`) +- Observer pattern with CloudEvents (`observer.go`) — event emission infrastructure +- ReverseProxy circuit breaker (`modules/reverseproxy/circuit_breaker.go`) +- Database BDD health check stubs (`modules/database/bdd_connections_test.go`) +- HTTP server health monitoring BDD stubs (`modules/httpserver/bdd_health_monitoring_test.go`) + +**Must implement (entire core service is new):** +- `HealthStatus` enum (Unknown/Healthy/Degraded/Unhealthy) +- `HealthProvider` interface +- `HealthReport` and `AggregatedHealth` structs +- `AggregateHealthService` with provider registry, concurrent fan-out, caching +- Per-provider panic recovery +- Temporary error detection (→ Degraded) +- Provider adapters: Simple, Static, Composite +- Health events: `HealthEvaluatedEvent`, `HealthStatusChangedEvent` +- Cache with TTL + force refresh context key + +## Key Interfaces + +```go +type HealthStatus int +const ( + StatusUnknown HealthStatus = iota + StatusHealthy + StatusDegraded + StatusUnhealthy +) + +type HealthProvider interface { + HealthCheck(ctx context.Context) ([]HealthReport, error) +} + +type HealthReport struct { + Module string + Component string + Status HealthStatus + Message string + CheckedAt time.Time + ObservedSince time.Time + Optional bool + Details map[string]any +} + +type AggregatedHealth struct { + Readiness HealthStatus + Health HealthStatus + Reports []HealthReport + GeneratedAt time.Time +} +``` + +## Architecture + +- Provider registry: `map[string]HealthProvider` behind `sync.RWMutex` +- Cache: single `AggregatedHealth` with TTL (default 250ms), invalidated on provider add/remove +- Force refresh: `context.WithValue(ctx, ForceHealthRefreshKey, true)` +- Concurrent collection: fan-out goroutines, per-provider panic recovery, channel-based results +- Aggregation: Readiness = worst non-optional, Health = worst all. Unknown → Unhealthy for aggregation +- Temporary errors (`interface{ Temporary() bool }`) → Degraded; other errors → Unhealthy + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `health.go` | HealthStatus enum, HealthProvider, HealthReport, AggregatedHealth, provider adapters | +| Create | `health_service.go` | AggregateHealthService implementation | +| Modify | `observer.go` | Add EventTypeHealthEvaluated, EventTypeHealthStatusChanged | +| Create | `health_test.go` | Unit + concurrency + panic recovery tests | + +## Implementation Checklist + +- [ ] Define HealthStatus enum with String() and IsHealthy() +- [ ] Define HealthProvider interface +- [ ] Define HealthReport and AggregatedHealth structs +- [ ] Add health event constants to observer.go +- [ ] Implement AggregateHealthService with provider registry + RWMutex +- [ ] Implement concurrent fan-out collection with goroutines + channel +- [ ] Implement per-provider panic recovery (panic → Unhealthy with details) +- [ ] Implement aggregation logic (readiness = worst non-optional, health = worst all) +- [ ] Implement cache with TTL (250ms default) and force-refresh context key +- [ ] Implement cache invalidation on provider add/remove +- [ ] Implement NewSimpleHealthProvider adapter +- [ ] Implement NewStaticHealthProvider adapter +- [ ] Implement NewCompositeHealthProvider adapter +- [ ] Implement temporary error detection (Degraded vs Unhealthy) +- [ ] Emit HealthEvaluatedEvent after each aggregation +- [ ] Emit HealthStatusChangedEvent on status transitions only +- [ ] Write unit tests: single provider, multiple providers, optional vs required +- [ ] Write cache tests: hit, miss, invalidation, force refresh +- [ ] Write concurrency tests: parallel checks, registration during check +- [ ] Write panic recovery tests + +## Notes + +- 250ms cache TTL prevents health check storms while keeping results fresh. +- Panic recovery ensures one misbehaving provider cannot crash the health system. +- `ObservedSince` tracks when current status was first seen, enabling duration-based alerting. +- Optional providers affect Health but not Readiness. +- Module-specific providers (cache, database, eventbus, reverseproxy) are examples, not required for core. diff --git a/docs/plans/bdd-contract-testing.md b/docs/plans/bdd-contract-testing.md new file mode 100644 index 00000000..469e12bc --- /dev/null +++ b/docs/plans/bdd-contract-testing.md @@ -0,0 +1,80 @@ +# BDD/Contract Testing Framework — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists (~65%):** +- Godog dependency (`go.mod`: `github.com/cucumber/godog v0.15.1`) +- 21 Gherkin feature files across core + modules +- 121 BDD test files across the codebase +- Core framework BDD: lifecycle, config, cycle detection, service registry, logger decorator +- Module BDD: auth, cache, database, eventbus, httpserver, httpclient, scheduler, reverseproxy, etc. +- Contract CLI: `modcli contract extract|compare|git-diff|tags` (`cmd/modcli/cmd/contract.go`, 636 lines) +- Contract types: `Contract`, `InterfaceContract`, `BreakingChange`, `ContractDiff` (`cmd/modcli/internal/contract/`) +- Contract extractor + differ with tests (1715 lines across 6 files) +- CI: `contract-check.yml` (241 lines) — extracts, compares, comments on PRs +- CI: `bdd-matrix.yml` (215 lines) — parallel module BDD, coverage merging +- BDD scripts: `run-module-bdd-parallel.sh`, `verify-bdd-tests.sh` + +**Must implement (depends on Dynamic Reload + Aggregate Health):** +- Reload contract feature file + step definitions (depends on Reloadable interface) +- Health contract feature file + step definitions (depends on HealthProvider interface) +- `ContractVerifier` interface for reload + health contracts +- Performance benchmark BDD (4 targets: bootstrap, lookup, reload, health) +- Concurrency stress test BDD scenarios + +## What to Build + +Since the BDD infrastructure and contract tooling are fully operational, the remaining work is: + +1. **Reload contract BDD** — write after Dynamic Reload is implemented +2. **Health contract BDD** — write after Aggregate Health is implemented +3. **ContractVerifier** — programmatic verification of reload/health behavioral contracts +4. **Performance benchmarks** — formalize the 4 targets as Go benchmarks + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `features/reload_contract.feature` | Gherkin scenarios for Reloadable contract | +| Create | `features/health_contract.feature` | Gherkin scenarios for HealthProvider contract | +| Create | `reload_contract_bdd_test.go` | Step definitions for reload scenarios | +| Create | `health_contract_bdd_test.go` | Step definitions for health scenarios | +| Create | `contract_verifier.go` | ContractVerifier interface + implementations | +| Create | `contract_verifier_test.go` | Verifier tests | +| Create | `benchmark_test.go` | Performance benchmarks for 4 targets | + +## Implementation Checklist + +- [x] ~~Add godog dependency~~ (exists) +- [x] ~~Create features/ directory with core Gherkin files~~ (6 files exist) +- [x] ~~Write step definitions for lifecycle, config, cycle detection, service registry~~ (121 BDD tests) +- [x] ~~Implement ContractExtractor and ContractSnapshot~~ (contract package complete) +- [x] ~~Implement modcli contract extract/compare~~ (636-line CLI) +- [x] ~~Add CI contract comparison on PRs~~ (contract-check.yml) +- [ ] Create reload_contract.feature (after Dynamic Reload is implemented) +- [ ] Write reload contract step definitions +- [ ] Create health_contract.feature (after Aggregate Health is implemented) +- [ ] Write health contract step definitions +- [ ] Implement ContractVerifier for reload contracts +- [ ] Implement ContractVerifier for health contracts +- [ ] Write performance benchmarks (bootstrap <150ms, lookup <2us, reload <80ms, health <5ms) +- [ ] Write concurrency stress test scenarios + +## Performance Targets + +| Metric | Target (P50) | +|--------|-------------| +| Bootstrap (10 modules) | <150ms | +| Service lookup | <2us | +| Reload | <80ms | +| Health aggregation | <5ms | + +## Notes + +- Reload/health contract BDD depends on those features being implemented first. +- Performance targets are P50 on commodity hardware; CI tracks regressions, not absolutes. +- Constitution rules (no interface widening, additive only) are already enforced by contract-check.yml. +- Godog integrates with `testing.T` via `godog.TestSuite`. +- Feature files should be readable by non-engineers. diff --git a/docs/plans/dynamic-reload.md b/docs/plans/dynamic-reload.md new file mode 100644 index 00000000..1bee18a9 --- /dev/null +++ b/docs/plans/dynamic-reload.md @@ -0,0 +1,134 @@ +# Dynamic Reload Manager — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists:** +- Observer pattern with CloudEvents (`observer.go`) — foundation for reload events +- Config field tracking (`config_field_tracking.go`) — `FieldPopulation`, `StructStateDiffer` +- Config providers with thread-safe variants (`config_provider.go`) — `ImmutableConfigProvider` (atomic.Value) +- Circuit breaker pattern in reverseproxy (`modules/reverseproxy/circuit_breaker.go`) — reference implementation +- `EventTypeConfigChanged` event constant +- Module interfaces: `Module`, `Configurable`, `Startable`, `Stoppable`, `DependencyAware` +- Builder pattern with `WithOnConfigLoaded()` option + +**Must implement:** +- `Reloadable` interface (add to `module.go`) +- `ConfigChange`, `ConfigDiff`, `FieldChange` types +- `ReloadTrigger` enum +- `ReloadOrchestrator` with request queue, CAS guard, circuit breaker +- Atomic reload with rollback semantics +- Reload lifecycle events (4 new event types) +- `RequestReload()` on Application interface +- `WithDynamicReload()` builder option +- Tests + +## Key Interfaces + +```go +type Reloadable interface { + Reload(ctx context.Context, changes []ConfigChange) error + CanReload() bool + ReloadTimeout() time.Duration +} + +type ConfigChange struct { + Section string + FieldPath string + OldValue any + NewValue any + Source string +} + +type ConfigDiff struct { + Changed map[string]FieldChange + Added map[string]FieldChange + Removed map[string]FieldChange + Timestamp time.Time + DiffID string +} + +type FieldChange struct { + OldValue any + NewValue any + FieldPath string + ChangeType ChangeType // Added, Modified, Removed + IsSensitive bool + ValidationResult error +} + +type ReloadTrigger int + +const ( + ReloadManual ReloadTrigger = iota + ReloadFileChange + ReloadAPIRequest + ReloadScheduled +) +``` + +## Architecture + +**ReloadOrchestrator** is the central coordinator: +- Module registry: `map[string]Reloadable` behind `sync.RWMutex` +- Request queue: buffered channel (capacity 100) of `ReloadRequest` +- Processing flag: `atomic.Bool` with CAS to ensure single-flight processing +- Background goroutine drains the request queue + +**Circuit breaker** with exponential backoff: +- Base delay: 2 seconds, max delay cap: 2 minutes +- Formula: `min(base * 2^(failures-1), cap)` +- Resets on successful reload, rejects while open + +**Atomic reload semantics**: +1. Compute `ConfigDiff` between old and new config +2. Filter modules by affected sections +3. Check `CanReload()` on each; skip those returning false +4. Apply changes with per-module timeout from `ReloadTimeout()` +5. On failure: roll back already-applied modules with reverse changes +6. Emit completion or failure event + +**Events** (add to observer.go): +- `EventTypeConfigReloadStarted` +- `EventTypeConfigReloadCompleted` +- `EventTypeConfigReloadFailed` +- `EventTypeConfigReloadNoop` + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `reload.go` | ConfigChange, ConfigDiff, FieldChange, ReloadTrigger types + ConfigDiff methods | +| Modify | `module.go` | Add Reloadable interface | +| Create | `reload_orchestrator.go` | ReloadOrchestrator implementation | +| Modify | `observer.go` | Add 4 reload event type constants | +| Modify | `application.go` | Add RequestReload() method | +| Modify | `builder.go` | Add WithDynamicReload() option | +| Create | `reload_test.go` | Unit + concurrency tests | + +## Implementation Checklist + +- [ ] Define `Reloadable` interface in module.go +- [ ] Create reload.go with ConfigChange, ConfigDiff, FieldChange, ChangeType, ReloadTrigger +- [ ] Implement ConfigDiff methods: HasChanges, FilterByPrefix, RedactSensitiveFields, ChangeSummary +- [ ] Add 4 reload event constants to observer.go +- [ ] Implement ReloadOrchestrator with module registry + RWMutex +- [ ] Implement channel-based request queue (buffered, size 100) +- [ ] Implement atomic CAS processing guard +- [ ] Implement exponential backoff circuit breaker +- [ ] Implement atomic reload with rollback on failure +- [ ] Implement per-module timeout via context cancellation +- [ ] Emit reload lifecycle events via observer +- [ ] Add RequestReload() to Application interface + StdApplication +- [ ] Add WithDynamicReload() builder option +- [ ] Write unit tests: successful reload, partial failure + rollback, circuit breaker +- [ ] Write concurrency tests: concurrent requests, CAS contention + +## Notes + +- Modules returning `CanReload() == false` are skipped, not errors. +- Rollback applies reverse ConfigChange entries in reverse module order. +- Queue drops requests when full (capacity 100) and returns error. +- Circuit breaker state is internal to orchestrator; not exposed to modules. +- Sensitive field detection uses configurable field path patterns (e.g., `*password*`, `*secret*`). diff --git a/docs/plans/tenant-guard.md b/docs/plans/tenant-guard.md new file mode 100644 index 00000000..7f7fc915 --- /dev/null +++ b/docs/plans/tenant-guard.md @@ -0,0 +1,118 @@ +# TenantGuard Framework — Revised Implementation Plan + +> Reset from CrisisTextLine/modular upstream (2026-03-09). This revision reflects what already exists. + +## Gap Analysis + +**Already exists (~50% complete):** +- `TenantContext` with context propagation (`tenant.go:51-94`) +- `TenantService` interface + `StandardTenantService` implementation (`tenant.go`, `tenant_service.go`) +- `TenantAwareModule` interface with lifecycle hooks (`tenant.go:211-230`) +- `TenantConfigProvider` with RWMutex, isolation, immutability variants (`tenant_config_provider.go`) +- `TenantConfigLoader` + file-based implementation (`tenant_config_loader.go`, `tenant_config_file_loader.go`) +- `TenantAwareConfig` context-aware resolution (`tenant_aware_config.go`) +- `TenantAwareDecorator` application decorator (`decorator_tenant.go`) +- `TenantAffixedEnvFeeder` for tenant-specific env vars (`feeders/tenant_affixed_env.go`) +- `WithTenantAware()` builder option (`builder.go:163-169`) +- 8 tenant sentinel errors in `errors.go` +- ~28 test files covering tenant basics + +**Must implement:** +- `TenantGuard` interface + `StandardTenantGuard` implementation +- `TenantGuardMode` enum (Strict/Lenient/Disabled) +- `ViolationType` + `Severity` enums +- `TenantViolation` struct +- `TenantGuardConfig` with defaults +- Ring buffer for bounded violation history +- Whitelist support +- `WithTenantGuardMode()` + `WithTenantGuardModeConfig()` builder options +- 2 missing sentinel errors +- Violation event emission via observer +- Mode-specific tests + concurrency tests + +## Key Types (new) + +```go +type TenantGuardMode int +const ( + TenantGuardStrict TenantGuardMode = iota + TenantGuardLenient + TenantGuardDisabled +) + +type ViolationType int +const ( + CrossTenant ViolationType = iota + InvalidContext + MissingContext + Unauthorized +) + +type Severity int +const ( + SeverityLow Severity = iota + SeverityMedium + SeverityHigh + SeverityCritical +) + +type TenantViolation struct { + Type ViolationType + Severity Severity + TenantID string + TargetID string + Timestamp time.Time + Details string +} + +type TenantGuard interface { + GetMode() TenantGuardMode + ValidateAccess(ctx context.Context, violation TenantViolation) error + GetRecentViolations() []TenantViolation +} + +type TenantGuardConfig struct { + Mode TenantGuardMode + EnforceIsolation bool + AllowCrossTenant bool + ValidationTimeout time.Duration + Whitelist map[string][]string + MaxViolations int + LogViolations bool +} +``` + +## Files + +| Action | File | What | +|--------|------|------| +| Create | `tenant_guard.go` | TenantGuardMode, ViolationType, Severity enums, TenantViolation, TenantGuardConfig, TenantGuard interface, StandardTenantGuard with ring buffer | +| Modify | `errors.go` | Add ErrTenantContextMissing, ErrTenantIsolationViolation | +| Modify | `builder.go` | Add WithTenantGuardMode(), WithTenantGuardModeConfig() | +| Modify | `observer.go` | Add EventTypeTenantViolation constant | +| Create | `tenant_guard_test.go` | Unit + concurrency tests | + +## Implementation Checklist + +- [ ] Create tenant_guard.go with TenantGuardMode enum + String() +- [ ] Add ViolationType and Severity enums with String() methods +- [ ] Implement TenantViolation struct +- [ ] Implement TenantGuardConfig with defaults (MaxViolations: 1000, LogViolations: true) +- [ ] Implement StandardTenantGuard with RWMutex-protected ring buffer +- [ ] Implement ValidateAccess: strict returns error, lenient logs, disabled no-op +- [ ] Implement whitelist checking in ValidateAccess +- [ ] Implement GetRecentViolations with deep copy +- [ ] Add ErrTenantContextMissing and ErrTenantIsolationViolation to errors.go +- [ ] Add EventTypeTenantViolation to observer.go +- [ ] Add WithTenantGuardMode() and WithTenantGuardModeConfig() to builder.go +- [ ] Write tests: strict blocks, lenient logs, disabled skips +- [ ] Write tests: whitelist bypass, ring buffer FIFO eviction +- [ ] Write concurrency tests: parallel ValidateAccess, concurrent violations + +## Notes + +- Ring buffer bounded at MaxViolations (default 1000) entries; FIFO eviction when full. +- Strict mode returns ErrTenantIsolationViolation; lenient logs + returns nil. +- GetRecentViolations() deep-copies to prevent caller mutation. +- Whitelist allows explicit cross-tenant access for service accounts. +- Emit EventTypeTenantViolation via observer for external monitoring integration. diff --git a/drainable.go b/drainable.go new file mode 100644 index 00000000..d6794e23 --- /dev/null +++ b/drainable.go @@ -0,0 +1,15 @@ +package modular + +import ( + "context" + "time" +) + +// Drainable is an optional interface for modules that need a pre-stop drain phase. +// During shutdown, PreStop is called on all Drainable modules (reverse dependency order) +// before Stop is called on Stoppable modules. +type Drainable interface { + PreStop(ctx context.Context) error +} + +const defaultDrainTimeout = 15 * time.Second diff --git a/drainable_test.go b/drainable_test.go new file mode 100644 index 00000000..ac8d3586 --- /dev/null +++ b/drainable_test.go @@ -0,0 +1,71 @@ +package modular + +import ( + "context" + "testing" + "time" +) + +type drainableModule struct { + name string + preStopSeq *[]string + stopSeq *[]string +} + +func (m *drainableModule) Name() string { return m.name } +func (m *drainableModule) Init(app Application) error { return nil } +func (m *drainableModule) Start(ctx context.Context) error { return nil } +func (m *drainableModule) PreStop(ctx context.Context) error { + *m.preStopSeq = append(*m.preStopSeq, m.name) + return nil +} +func (m *drainableModule) Stop(ctx context.Context) error { + *m.stopSeq = append(*m.stopSeq, m.name) + return nil +} + +func TestDrainable_PreStopCalledBeforeStop(t *testing.T) { + preStops := make([]string, 0) + stops := make([]string, 0) + + mod := &drainableModule{name: "drainer", preStopSeq: &preStops, stopSeq: &stops} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + + if len(preStops) != 1 || preStops[0] != "drainer" { + t.Errorf("expected PreStop called for drainer, got %v", preStops) + } + if len(stops) != 1 || stops[0] != "drainer" { + t.Errorf("expected Stop called for drainer, got %v", stops) + } +} + +func TestDrainable_WithDrainTimeout(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithDrainTimeout(5*time.Second), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + if stdApp.drainTimeout != 5*time.Second { + t.Errorf("expected drain timeout 5s, got %v", stdApp.drainTimeout) + } +} diff --git a/enhanced_service_registry_test.go b/enhanced_service_registry_test.go index 6dbf0b50..70d04cfd 100644 --- a/enhanced_service_registry_test.go +++ b/enhanced_service_registry_test.go @@ -128,7 +128,7 @@ func TestEnhancedServiceRegistry_InterfaceDiscovery(t *testing.T) { registry.RegisterService("nonInterface", nonInterfaceService) // Discover by interface - interfaceType := reflect.TypeOf((*ServiceRegistryTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[ServiceRegistryTestInterface]() entries := registry.GetServicesByInterface(interfaceType) require.Len(t, entries, 2) diff --git a/errors.go b/errors.go index 8693c401..0b2c2528 100644 --- a/errors.go +++ b/errors.go @@ -82,6 +82,18 @@ var ( ErrMockTenantConfigsNotInitialized = errors.New("mock tenant configs not initialized") ErrConfigSectionNotFoundForTenant = errors.New("config section not found for tenant") + // Tenant guard errors + ErrTenantContextMissing = errors.New("tenant context is missing") + ErrTenantIsolationViolation = errors.New("tenant isolation violation") + + // Reload errors + ErrReloadCircuitBreakerOpen = errors.New("reload circuit breaker is open; backing off") + ErrReloadChannelFull = errors.New("reload request channel is full") + ErrReloadInProgress = errors.New("reload already in progress") + ErrReloadStopped = errors.New("reload orchestrator is stopped") + ErrReloadTimeout = errors.New("reload timed out waiting for module") + ErrDynamicReloadNotEnabled = errors.New("dynamic reload not enabled") + // Observer/Event emission errors ErrNoSubjectForEventEmission = errors.New("no subject available for event emission") diff --git a/event_emission_fix_test.go b/event_emission_fix_test.go index 58948e09..1feceb9c 100644 --- a/event_emission_fix_test.go +++ b/event_emission_fix_test.go @@ -62,7 +62,7 @@ func TestModuleEventEmissionWithoutSubject(t *testing.T) { } // testModuleNilSubjectHandling is a helper function that tests nil subject handling for a specific module -func testModuleNilSubjectHandling(t *testing.T, modulePath, moduleName string) { +func testModuleNilSubjectHandling(t *testing.T, _, moduleName string) { // Create a mock application for testing app := &mockApplicationForNilSubjectTest{} @@ -85,7 +85,7 @@ func testModuleNilSubjectHandling(t *testing.T, modulePath, moduleName string) { // Test the emitEvent helper pattern - this should not panic and should handle nil subject gracefully // We can't call the actual module's emitEvent helper directly since it's private, // but we can verify the pattern works by testing that no panic occurs - testModule.testEmitEventHelper(context.Background(), "test.event.type", map[string]interface{}{ + testModule.testEmitEventHelper(context.Background(), "test.event.type", map[string]any{ "test_key": "test_value", }) } @@ -135,7 +135,7 @@ func (t *testObservableModuleForNilSubject) EmitEvent(ctx context.Context, event } // testEmitEventHelper simulates the pattern used by modules' emitEvent helper methods -func (t *testObservableModuleForNilSubject) testEmitEventHelper(ctx context.Context, eventType string, data map[string]interface{}) { +func (t *testObservableModuleForNilSubject) testEmitEventHelper(ctx context.Context, eventType string, data map[string]any) { // This simulates the pattern used in modules - check for nil subject first if t.subject == nil { return // Should return silently without error @@ -162,13 +162,13 @@ type mockTestLogger struct { lastDebugMessage string } -func (l *mockTestLogger) Debug(msg string, args ...interface{}) { +func (l *mockTestLogger) Debug(msg string, args ...any) { l.lastDebugMessage = msg } -func (l *mockTestLogger) Info(msg string, args ...interface{}) {} -func (l *mockTestLogger) Warn(msg string, args ...interface{}) {} -func (l *mockTestLogger) Error(msg string, args ...interface{}) {} +func (l *mockTestLogger) Info(msg string, args ...any) {} +func (l *mockTestLogger) Warn(msg string, args ...any) {} +func (l *mockTestLogger) Error(msg string, args ...any) {} type mockApplicationForNilSubjectTest struct{} diff --git a/examples/advanced-logging/go.mod b/examples/advanced-logging/go.mod index a0ed63b9..242e28d2 100644 --- a/examples/advanced-logging/go.mod +++ b/examples/advanced-logging/go.mod @@ -1,15 +1,15 @@ module advanced-logging -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpclient v0.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpclient v0.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.0 ) require ( @@ -27,12 +27,12 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpclient => ../../modules/httpclient +replace github.com/GoCodeAlone/modular/modules/httpclient => ../../modules/httpclient -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/advanced-logging/main.go b/examples/advanced-logging/main.go index 6cee0b71..eb36cd9c 100644 --- a/examples/advanced-logging/main.go +++ b/examples/advanced-logging/main.go @@ -7,12 +7,12 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpclient" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpclient" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/base-config-example/go.mod b/examples/base-config-example/go.mod index adf78c88..174de6a5 100644 --- a/examples/base-config-example/go.mod +++ b/examples/base-config-example/go.mod @@ -1,8 +1,8 @@ -module github.com/CrisisTextLine/modular/examples/base-config-example +module github.com/GoCodeAlone/modular/examples/base-config-example -go 1.25 +go 1.26 -require github.com/CrisisTextLine/modular v1.11.9 +require github.com/GoCodeAlone/modular v1.11.9 require ( github.com/BurntSushi/toml v1.6.0 // indirect @@ -17,4 +17,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ diff --git a/examples/base-config-example/main.go b/examples/base-config-example/main.go index 2cdc4ae9..85bd6d0a 100644 --- a/examples/base-config-example/main.go +++ b/examples/base-config-example/main.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // AppConfig represents our application configuration diff --git a/examples/basic-app/api/api.go b/examples/basic-app/api/api.go index eb94315f..bdc2e791 100644 --- a/examples/basic-app/api/api.go +++ b/examples/basic-app/api/api.go @@ -5,7 +5,7 @@ import ( "net/http" "reflect" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" ) diff --git a/examples/basic-app/go.mod b/examples/basic-app/go.mod index da31dbfa..9544bdf3 100644 --- a/examples/basic-app/go.mod +++ b/examples/basic-app/go.mod @@ -1,11 +1,11 @@ module basic-app -go 1.25 +go 1.26 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ require ( - github.com/CrisisTextLine/modular v1.11.9 + github.com/GoCodeAlone/modular v1.11.9 github.com/go-chi/chi/v5 v5.2.2 ) diff --git a/examples/basic-app/main.go b/examples/basic-app/main.go index 0efae3af..7ca8013b 100644 --- a/examples/basic-app/main.go +++ b/examples/basic-app/main.go @@ -8,8 +8,8 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) func main() { diff --git a/examples/basic-app/router/router.go b/examples/basic-app/router/router.go index 64b24d75..a694d4ea 100644 --- a/examples/basic-app/router/router.go +++ b/examples/basic-app/router/router.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) diff --git a/examples/basic-app/webserver/webserver.go b/examples/basic-app/webserver/webserver.go index 5f013b54..d43d2a77 100644 --- a/examples/basic-app/webserver/webserver.go +++ b/examples/basic-app/webserver/webserver.go @@ -9,7 +9,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) const configSection = "webserver" diff --git a/examples/feature-flag-proxy/go.mod b/examples/feature-flag-proxy/go.mod index ab4df210..ce9bf5cf 100644 --- a/examples/feature-flag-proxy/go.mod +++ b/examples/feature-flag-proxy/go.mod @@ -1,14 +1,14 @@ module feature-flag-proxy -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.2 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.2 ) require ( @@ -26,10 +26,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/feature-flag-proxy/main.go b/examples/feature-flag-proxy/main.go index 42921d3d..9a8bd56d 100644 --- a/examples/feature-flag-proxy/main.go +++ b/examples/feature-flag-proxy/main.go @@ -7,11 +7,11 @@ import ( "os" "regexp" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/feature-flag-proxy/main_test.go b/examples/feature-flag-proxy/main_test.go index e747168d..ee92b606 100644 --- a/examples/feature-flag-proxy/main_test.go +++ b/examples/feature-flag-proxy/main_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) // TestFeatureFlagEvaluatorIntegration tests the integration between modules diff --git a/examples/health-aware-reverse-proxy/go.mod b/examples/health-aware-reverse-proxy/go.mod index dd81b896..0cc5090e 100644 --- a/examples/health-aware-reverse-proxy/go.mod +++ b/examples/health-aware-reverse-proxy/go.mod @@ -1,14 +1,14 @@ module health-aware-reverse-proxy -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/httpserver v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/chimux v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/httpserver v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 ) require ( @@ -26,12 +26,12 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/feeders => ../../feeders +replace github.com/GoCodeAlone/modular/feeders => ../../feeders diff --git a/examples/health-aware-reverse-proxy/main.go b/examples/health-aware-reverse-proxy/main.go index b5066822..18c0ae8b 100644 --- a/examples/health-aware-reverse-proxy/main.go +++ b/examples/health-aware-reverse-proxy/main.go @@ -9,11 +9,11 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/http-client/go.mod b/examples/http-client/go.mod index 939cb4d7..9ec5b69e 100644 --- a/examples/http-client/go.mod +++ b/examples/http-client/go.mod @@ -1,15 +1,15 @@ module http-client -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpclient v0.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpclient v0.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.0 github.com/stretchr/testify v1.11.1 ) @@ -30,12 +30,12 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpclient => ../../modules/httpclient +replace github.com/GoCodeAlone/modular/modules/httpclient => ../../modules/httpclient -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/http-client/gzip_logging_integration_test.go b/examples/http-client/gzip_logging_integration_test.go index a5fd346e..ea3b2e97 100644 --- a/examples/http-client/gzip_logging_integration_test.go +++ b/examples/http-client/gzip_logging_integration_test.go @@ -12,8 +12,8 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/httpclient" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/httpclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/examples/http-client/main.go b/examples/http-client/main.go index 488d183d..4de12df6 100644 --- a/examples/http-client/main.go +++ b/examples/http-client/main.go @@ -4,12 +4,12 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpclient" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpclient" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/instance-aware-db/go.mod b/examples/instance-aware-db/go.mod index 88d445c7..b97cca6e 100644 --- a/examples/instance-aware-db/go.mod +++ b/examples/instance-aware-db/go.mod @@ -1,21 +1,20 @@ module instance-aware-db -go 1.25 +go 1.26 -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/database => ../../modules/database +replace github.com/GoCodeAlone/modular/modules/database/v2 => ../../modules/database require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/database v1.4.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 github.com/mattn/go-sqlite3 v1.14.32 ) require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect diff --git a/examples/instance-aware-db/go.sum b/examples/instance-aware-db/go.sum index bb8f738b..ac3f21fa 100644 --- a/examples/instance-aware-db/go.sum +++ b/examples/instance-aware-db/go.sum @@ -5,8 +5,6 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/examples/instance-aware-db/main.go b/examples/instance-aware-db/main.go index d0a611f3..ae403aab 100644 --- a/examples/instance-aware-db/main.go +++ b/examples/instance-aware-db/main.go @@ -6,9 +6,9 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/database/v2" // Import SQLite driver _ "github.com/mattn/go-sqlite3" diff --git a/examples/logger-reconfiguration/go.mod b/examples/logger-reconfiguration/go.mod index 39e7b3ca..80c81cac 100644 --- a/examples/logger-reconfiguration/go.mod +++ b/examples/logger-reconfiguration/go.mod @@ -1,10 +1,10 @@ module logger-reconfiguration -go 1.25 +go 1.26 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -require github.com/CrisisTextLine/modular v1.11.9-00010101000000-000000000000 +require github.com/GoCodeAlone/modular v1.11.9-00010101000000-000000000000 require ( github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/examples/logger-reconfiguration/main.go b/examples/logger-reconfiguration/main.go index 4f1525d1..999c655d 100644 --- a/examples/logger-reconfiguration/main.go +++ b/examples/logger-reconfiguration/main.go @@ -5,8 +5,8 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) func main() { @@ -30,7 +30,7 @@ func main() { // Create a new logger based on configuration settings var handler slog.Handler - + // Configure handler based on log format switch cfg.LogFormat { case "json": @@ -49,10 +49,10 @@ func main() { // Create new logger with configuration-based settings newLogger := slog.New(handler) - + // Replace the logger before modules initialize app.SetLogger(newLogger) - + newLogger.Info("Logger reconfigured from configuration", "format", cfg.LogFormat, "level", cfg.LogLevel) @@ -154,11 +154,11 @@ func (m *ServiceModule) Init(app modular.Application) error { // This module also caches the logger m.logger = app.Logger() m.logger.Info("ServiceModule initialized", "module", m.Name(), "status", "ready") - + // Demonstrate that the logger has the correct configuration - m.logger.Debug("Service module debug information", + m.logger.Debug("Service module debug information", "feature", "logger_reconfiguration", "working", true) - + return nil } diff --git a/examples/logmasker-example/go.mod b/examples/logmasker-example/go.mod index 8d79d2ce..5fafc16b 100644 --- a/examples/logmasker-example/go.mod +++ b/examples/logmasker-example/go.mod @@ -1,10 +1,10 @@ module logmasker-example -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/logmasker v0.0.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/logmasker v0.0.0 ) require ( @@ -20,6 +20,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/logmasker => ../../modules/logmasker +replace github.com/GoCodeAlone/modular/modules/logmasker => ../../modules/logmasker diff --git a/examples/logmasker-example/main.go b/examples/logmasker-example/main.go index ad49da9b..ff4cce56 100644 --- a/examples/logmasker-example/main.go +++ b/examples/logmasker-example/main.go @@ -3,8 +3,8 @@ package main import ( "log" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/logmasker" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/logmasker" ) // SimpleLogger implements modular.Logger for demonstration diff --git a/examples/multi-engine-eventbus/go.mod b/examples/multi-engine-eventbus/go.mod index 4824f21d..8405fb4d 100644 --- a/examples/multi-engine-eventbus/go.mod +++ b/examples/multi-engine-eventbus/go.mod @@ -1,17 +1,18 @@ module multi-engine-eventbus -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/eventbus v1.7.0 ) require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.4.0 // indirect + github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.1 // indirect github.com/IBM/sarama v1.45.2 // indirect github.com/Microsoft/go-winio v0.5.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.0 // indirect @@ -71,6 +72,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/eventbus => ../../modules/eventbus +replace github.com/GoCodeAlone/modular/modules/eventbus => ../../modules/eventbus diff --git a/examples/multi-engine-eventbus/go.sum b/examples/multi-engine-eventbus/go.sum index bd3ee7f0..4ce86877 100644 --- a/examples/multi-engine-eventbus/go.sum +++ b/examples/multi-engine-eventbus/go.sum @@ -2,6 +2,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go/v5 v5.4.0 h1:Ea3eXUVwrVV28F/fo3Dr3aa+TL/Z7Xi6SUPKW8L99aI= github.com/DataDog/datadog-go/v5 v5.4.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.1 h1:GTSJh+QbPj7nuXoiiz53+DPxJ3xw7JPemzBuWg6vKS4= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.1/go.mod h1:PvgkUxMg2RL/TjKevO3PBTy+RazZX5YXi8IK/Bz1qcw= github.com/IBM/sarama v1.45.2 h1:8m8LcMCu3REcwpa7fCP6v2fuPuzVwXDAM2DOv3CBrKw= github.com/IBM/sarama v1.45.2/go.mod h1:ppaoTcVdGv186/z6MEKsMm70A5fwJfRTpstI37kVn3Y= github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= diff --git a/examples/multi-engine-eventbus/main.go b/examples/multi-engine-eventbus/main.go index a5f88563..18bfae90 100644 --- a/examples/multi-engine-eventbus/main.go +++ b/examples/multi-engine-eventbus/main.go @@ -7,8 +7,8 @@ import ( "net" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventbus" ) // testLogger is a simple logger for the example diff --git a/examples/multi-tenant-app/go.mod b/examples/multi-tenant-app/go.mod index 2e91abc6..e2e78871 100644 --- a/examples/multi-tenant-app/go.mod +++ b/examples/multi-tenant-app/go.mod @@ -1,10 +1,10 @@ module multi-tenant-app -go 1.25 +go 1.26 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -require github.com/CrisisTextLine/modular v1.11.9 +require github.com/GoCodeAlone/modular v1.11.9 require ( github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/examples/multi-tenant-app/main.go b/examples/multi-tenant-app/main.go index d8c51598..44a6dbd7 100644 --- a/examples/multi-tenant-app/main.go +++ b/examples/multi-tenant-app/main.go @@ -6,8 +6,8 @@ import ( "os" "regexp" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) func main() { diff --git a/examples/multi-tenant-app/modules.go b/examples/multi-tenant-app/modules.go index 4893ff77..4c53d75f 100644 --- a/examples/multi-tenant-app/modules.go +++ b/examples/multi-tenant-app/modules.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static error variables for err113 compliance diff --git a/examples/nats-eventbus/go.mod b/examples/nats-eventbus/go.mod index 006ff862..a01afac6 100644 --- a/examples/nats-eventbus/go.mod +++ b/examples/nats-eventbus/go.mod @@ -1,21 +1,22 @@ module nats-eventbus -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/eventbus => ../../modules/eventbus +replace github.com/GoCodeAlone/modular/modules/eventbus => ../../modules/eventbus require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/eventbus v1.7.0 ) require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.4.0 // indirect + github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.1 // indirect github.com/IBM/sarama v1.45.2 // indirect github.com/Microsoft/go-winio v0.5.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.0 // indirect diff --git a/examples/nats-eventbus/go.sum b/examples/nats-eventbus/go.sum index bd3ee7f0..4ce86877 100644 --- a/examples/nats-eventbus/go.sum +++ b/examples/nats-eventbus/go.sum @@ -2,6 +2,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go/v5 v5.4.0 h1:Ea3eXUVwrVV28F/fo3Dr3aa+TL/Z7Xi6SUPKW8L99aI= github.com/DataDog/datadog-go/v5 v5.4.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.1 h1:GTSJh+QbPj7nuXoiiz53+DPxJ3xw7JPemzBuWg6vKS4= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.1/go.mod h1:PvgkUxMg2RL/TjKevO3PBTy+RazZX5YXi8IK/Bz1qcw= github.com/IBM/sarama v1.45.2 h1:8m8LcMCu3REcwpa7fCP6v2fuPuzVwXDAM2DOv3CBrKw= github.com/IBM/sarama v1.45.2/go.mod h1:ppaoTcVdGv186/z6MEKsMm70A5fwJfRTpstI37kVn3Y= github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= diff --git a/examples/nats-eventbus/main.go b/examples/nats-eventbus/main.go index 09d0ae1c..470e2560 100644 --- a/examples/nats-eventbus/main.go +++ b/examples/nats-eventbus/main.go @@ -12,8 +12,8 @@ import ( "syscall" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventbus" ) // testLogger is a simple logger for the example diff --git a/examples/observer-demo/go.mod b/examples/observer-demo/go.mod index 008cb054..c9de81f0 100644 --- a/examples/observer-demo/go.mod +++ b/examples/observer-demo/go.mod @@ -1,16 +1,16 @@ module observer-demo -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/eventlogger => ../../modules/eventlogger +replace github.com/GoCodeAlone/modular/modules/eventlogger => ../../modules/eventlogger require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 github.com/cloudevents/sdk-go/v2 v2.16.2 ) diff --git a/examples/observer-demo/main.go b/examples/observer-demo/main.go index 371ff4af..3c6bb127 100644 --- a/examples/observer-demo/main.go +++ b/examples/observer-demo/main.go @@ -7,8 +7,8 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventlogger" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/audit_module.go b/examples/observer-pattern/audit_module.go index c690058c..b1467e01 100644 --- a/examples/observer-pattern/audit_module.go +++ b/examples/observer-pattern/audit_module.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/cloudevents_module.go b/examples/observer-pattern/cloudevents_module.go index b91d2a8d..3d6a5eb6 100644 --- a/examples/observer-pattern/cloudevents_module.go +++ b/examples/observer-pattern/cloudevents_module.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/go.mod b/examples/observer-pattern/go.mod index 2ebb342d..98badfa3 100644 --- a/examples/observer-pattern/go.mod +++ b/examples/observer-pattern/go.mod @@ -1,12 +1,12 @@ module observer-pattern -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/eventlogger v0.0.0-00010101000000-000000000000 github.com/cloudevents/sdk-go/v2 v2.16.2 ) @@ -22,6 +22,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. -replace github.com/CrisisTextLine/modular/modules/eventlogger => ../../modules/eventlogger +replace github.com/GoCodeAlone/modular/modules/eventlogger => ../../modules/eventlogger diff --git a/examples/observer-pattern/main.go b/examples/observer-pattern/main.go index 699223ee..56c5f014 100644 --- a/examples/observer-pattern/main.go +++ b/examples/observer-pattern/main.go @@ -7,9 +7,9 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/eventlogger" ) func main() { diff --git a/examples/observer-pattern/notification_module.go b/examples/observer-pattern/notification_module.go index ad188f0e..a8fbcc71 100644 --- a/examples/observer-pattern/notification_module.go +++ b/examples/observer-pattern/notification_module.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/observer-pattern/user_module.go b/examples/observer-pattern/user_module.go index 1ea2857a..877b2ccf 100644 --- a/examples/observer-pattern/user_module.go +++ b/examples/observer-pattern/user_module.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/examples/reverse-proxy/config.yaml b/examples/reverse-proxy/config.yaml index 785805dd..c8ac8d30 100644 --- a/examples/reverse-proxy/config.yaml +++ b/examples/reverse-proxy/config.yaml @@ -28,6 +28,14 @@ reverseproxy: # Custom Transformer Backends profile-backend: "http://localhost:9013" analytics-backend: "http://localhost:9014" + + # Pipeline Strategy Backends + conversations-backend: "http://localhost:9015" + followup-backend: "http://localhost:9016" + + # Fan-Out-Merge Strategy Backends + tickets-backend: "http://localhost:9017" + assignments-backend: "http://localhost:9018" default_backend: "global-default" tenant_id_header: "X-Tenant-ID" @@ -100,6 +108,29 @@ reverseproxy: - "analytics-backend" strategy: "merge" # Strategy is set, but transformer overrides merge behavior + # STRATEGY 4: PIPELINE + # Executes backends sequentially where each stage's response informs the next request. + # Use case: A list page showing queued conversations. Backend A returns conversation + # details, those IDs are fed into Backend B to fetch follow-up information, + # and the responses are merged into a unified view. + "/api/composite/pipeline": + pattern: "/api/composite/pipeline" + backends: + - "conversations-backend" + - "followup-backend" + strategy: "pipeline" + + # STRATEGY 5: FAN-OUT-MERGE + # Executes all backends in parallel, then merges responses by matching IDs. + # Use case: A ticket dashboard where tickets come from one service and + # assignment/priority data comes from another. The merger correlates by ticket ID. + "/api/composite/fanout-merge": + pattern: "/api/composite/fanout-merge" + backends: + - "tickets-backend" + - "assignments-backend" + strategy: "fan-out-merge" + # ChiMux router configuration chimux: basepath: "" diff --git a/examples/reverse-proxy/go.mod b/examples/reverse-proxy/go.mod index 5a168cd8..19076da4 100644 --- a/examples/reverse-proxy/go.mod +++ b/examples/reverse-proxy/go.mod @@ -1,14 +1,14 @@ module reverse-proxy -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v1.1.0 - github.com/CrisisTextLine/modular/modules/httpserver v0.1.1 - github.com/CrisisTextLine/modular/modules/reverseproxy v1.1.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/chimux v1.1.0 + github.com/GoCodeAlone/modular/modules/httpserver v0.1.1 + github.com/GoCodeAlone/modular/modules/reverseproxy v1.1.0 ) require ( @@ -26,10 +26,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/reverse-proxy/main.go b/examples/reverse-proxy/main.go index 6285b495..3ac49762 100644 --- a/examples/reverse-proxy/main.go +++ b/examples/reverse-proxy/main.go @@ -2,19 +2,21 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" + "strings" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { @@ -168,6 +170,118 @@ func main() { return resp, nil }) + // PIPELINE STRATEGY EXAMPLE: + // This demonstrates chained backend requests where backend B's request is constructed + // using data from backend A's response. This is the map/reduce pattern. + // + // Use case: A list page shows queued conversations. Backend A returns conversation details, + // then those conversation IDs are fed into Backend B to fetch follow-up information. + // The responses are then merged to produce a unified view. + proxyModule.SetPipelineConfig("/api/composite/pipeline", reverseproxy.PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "followup-backend" { + // Extract conversation IDs from the conversations backend response + var convResp struct { + Conversations []struct { + ID string `json:"id"` + } `json:"conversations"` + } + if body, ok := previousResponses["conversations-backend"]; ok { + if err := json.Unmarshal(body, &convResp); err != nil { + return nil, fmt.Errorf("failed to parse conversations: %w", err) + } + } + + // Build the follow-up request with those IDs + ids := make([]string, 0, len(convResp.Conversations)) + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + idsParam := "" + for i, id := range ids { + if i > 0 { + idsParam += "," + } + idsParam += id + } + + url := "http://localhost:9016/followups?ids=" + idsParam + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown pipeline backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + // Parse conversations + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + if body, ok := allResponses["conversations-backend"]; ok { + json.Unmarshal(body, &convResp) + } + + // Parse follow-ups + var fuResp struct { + FollowUps map[string]interface{} `json:"follow_ups"` + } + if body, ok := allResponses["followup-backend"]; ok { + json.Unmarshal(body, &fuResp) + } + + // Merge follow-up data into each conversation + for i, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok { + if fu, exists := fuResp.FollowUps[id]; exists { + convResp.Conversations[i]["follow_up"] = fu + } + } + } + + return reverseproxy.MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "conversations": convResp.Conversations, + "strategy": "pipeline", + }) + }, + }) + + // FAN-OUT-MERGE STRATEGY EXAMPLE: + // This demonstrates parallel requests to multiple backends with custom ID-based + // response merging. Both backends are called simultaneously, then their responses + // are correlated by matching IDs. + // + // Use case: Show a ticket dashboard where tickets come from one service and + // priority/assignment data comes from another. The merger matches by ticket ID. + proxyModule.SetFanOutMerger("/api/composite/fanout-merge", func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Parse tickets from the tickets backend + var ticketsResp struct { + Tickets []map[string]interface{} `json:"tickets"` + } + if body, ok := responses["tickets-backend"]; ok { + json.Unmarshal(body, &ticketsResp) + } + + // Parse assignments from the assignments backend + var assignResp struct { + Assignments map[string]interface{} `json:"assignments"` + } + if body, ok := responses["assignments-backend"]; ok { + json.Unmarshal(body, &assignResp) + } + + // Merge assignments into tickets by ID + for i, ticket := range ticketsResp.Tickets { + if id, ok := ticket["id"].(string); ok { + if assignment, exists := assignResp.Assignments[id]; exists { + ticketsResp.Tickets[i]["assignment"] = assignment + } + } + } + + return reverseproxy.MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "tickets": ticketsResp.Tickets, + "strategy": "fan-out-merge", + }) + }) + app.RegisterModule(proxyModule) app.RegisterModule(httpserver.NewHTTPServerModule()) @@ -403,4 +517,89 @@ func startMockBackends() { fmt.Printf("Backend server error on :9014: %v\n", err) } }() + + // ======================================== + // Backends for PIPELINE strategy demonstration + // ======================================== + + // Conversations backend (port 9015) - Returns queued conversations + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"conversations":[{"id":"conv-1","status":"queued","counselor":"Alice","created_at":"2024-01-01T10:00:00Z"},{"id":"conv-2","status":"queued","counselor":"Bob","created_at":"2024-01-01T10:05:00Z"},{"id":"conv-3","status":"active","counselor":"Carol","created_at":"2024-01-01T10:10:00Z"}]}`) + }) + fmt.Println("Starting conversations-backend (pipeline demo) on :9015") + if err := http.ListenAndServe(":9015", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9015: %v\n", err) + } + }() + + // Follow-up backend (port 9016) - Returns follow-up details for given conversation IDs + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + idsParam := r.URL.Query().Get("ids") + followUps := make(map[string]interface{}) + if idsParam != "" { + for _, id := range strings.Split(idsParam, ",") { + switch id { + case "conv-1": + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "conv-50", + "follow_up_count": 2, + } + case "conv-3": + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "conv-90", + "follow_up_count": 1, + } + } + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp, _ := json.Marshal(map[string]interface{}{"follow_ups": followUps}) + w.Write(resp) //nolint:errcheck + }) + fmt.Println("Starting followup-backend (pipeline demo) on :9016") + if err := http.ListenAndServe(":9016", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9016: %v\n", err) + } + }() + + // ======================================== + // Backends for FAN-OUT-MERGE strategy demonstration + // ======================================== + + // Tickets backend (port 9017) - Returns support tickets + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"tickets":[{"id":"ticket-1","subject":"Login issue","status":"open","created":"2024-01-15"},{"id":"ticket-2","subject":"Billing question","status":"open","created":"2024-01-16"},{"id":"ticket-3","subject":"Feature request","status":"pending","created":"2024-01-17"}]}`) + }) + fmt.Println("Starting tickets-backend (fan-out-merge demo) on :9017") + if err := http.ListenAndServe(":9017", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9017: %v\n", err) + } + }() + + // Assignments backend (port 9018) - Returns ticket assignments and priorities + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"assignments":{"ticket-1":{"assignee":"Alice","priority":"high","sla_deadline":"2024-01-16T12:00:00Z"},"ticket-3":{"assignee":"Bob","priority":"low","sla_deadline":"2024-01-20T12:00:00Z"}}}`) + }) + fmt.Println("Starting assignments-backend (fan-out-merge demo) on :9018") + if err := http.ListenAndServe(":9018", mux); err != nil { //nolint:gosec + fmt.Printf("Backend server error on :9018: %v\n", err) + } + }() } diff --git a/examples/testing-scenarios/go.mod b/examples/testing-scenarios/go.mod index 7cb34163..ea2b1f92 100644 --- a/examples/testing-scenarios/go.mod +++ b/examples/testing-scenarios/go.mod @@ -1,14 +1,14 @@ module testing-scenarios -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/chimux v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/httpserver v0.0.0-00010101000000-000000000000 - github.com/CrisisTextLine/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/chimux v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/httpserver v0.0.0-00010101000000-000000000000 + github.com/GoCodeAlone/modular/modules/reverseproxy v0.0.0-00010101000000-000000000000 ) require ( @@ -26,10 +26,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/CrisisTextLine/modular => ../../ +replace github.com/GoCodeAlone/modular => ../../ -replace github.com/CrisisTextLine/modular/modules/chimux => ../../modules/chimux +replace github.com/GoCodeAlone/modular/modules/chimux => ../../modules/chimux -replace github.com/CrisisTextLine/modular/modules/httpserver => ../../modules/httpserver +replace github.com/GoCodeAlone/modular/modules/httpserver => ../../modules/httpserver -replace github.com/CrisisTextLine/modular/modules/reverseproxy => ../../modules/reverseproxy +replace github.com/GoCodeAlone/modular/modules/reverseproxy => ../../modules/reverseproxy diff --git a/examples/testing-scenarios/launchdarkly.go b/examples/testing-scenarios/launchdarkly.go index b553908e..7f14eed3 100644 --- a/examples/testing-scenarios/launchdarkly.go +++ b/examples/testing-scenarios/launchdarkly.go @@ -7,8 +7,8 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) // LaunchDarklyConfig provides configuration for LaunchDarkly integration. diff --git a/examples/testing-scenarios/main.go b/examples/testing-scenarios/main.go index e30addc1..60e0db8d 100644 --- a/examples/testing-scenarios/main.go +++ b/examples/testing-scenarios/main.go @@ -16,11 +16,11 @@ import ( "syscall" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) type AppConfig struct { diff --git a/examples/verbose-debug/go.mod b/examples/verbose-debug/go.mod index 0f1fcbf5..d27a0553 100644 --- a/examples/verbose-debug/go.mod +++ b/examples/verbose-debug/go.mod @@ -1,19 +1,18 @@ module verbose-debug -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/database v1.4.0 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/database/v2 v2.2.0 modernc.org/sqlite v1.38.0 ) require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 // indirect github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect @@ -66,7 +65,7 @@ require ( ) // Use local module for development -replace github.com/CrisisTextLine/modular => ../.. +replace github.com/GoCodeAlone/modular => ../.. // Use local database module for development -replace github.com/CrisisTextLine/modular/modules/database => ../../modules/database +replace github.com/GoCodeAlone/modular/modules/database/v2 => ../../modules/database diff --git a/examples/verbose-debug/go.sum b/examples/verbose-debug/go.sum index 24f3010b..17caa3ee 100644 --- a/examples/verbose-debug/go.sum +++ b/examples/verbose-debug/go.sum @@ -5,8 +5,6 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0 h1:/U3RgMzuVQDZOmVW2dZsrT8eJUiQB2QHo2TLW9EPj9Y= -github.com/CrisisTextLine/modular/modules/database/v2 v2.2.0/go.mod h1:sDD70d2nKzNfJGUOGBmzecO+4nxRA641M5UDdblBNK4= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/examples/verbose-debug/main.go b/examples/verbose-debug/main.go index a337f4f2..e49a4462 100644 --- a/examples/verbose-debug/main.go +++ b/examples/verbose-debug/main.go @@ -6,9 +6,9 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/database/v2" // Import SQLite driver for database connections _ "modernc.org/sqlite" diff --git a/features/health_contract.feature b/features/health_contract.feature new file mode 100644 index 00000000..354d0b32 --- /dev/null +++ b/features/health_contract.feature @@ -0,0 +1,47 @@ +Feature: Aggregate Health Contract + The health service must aggregate provider reports correctly. + + Scenario: Single healthy provider produces healthy status + Given a health service with one healthy provider + When health is checked + Then the overall status should be "healthy" + And readiness should be "healthy" + + Scenario: One unhealthy provider degrades overall health + Given a health service with one healthy and one unhealthy provider + When health is checked + Then the overall health should be "unhealthy" + And readiness should be "unhealthy" + + Scenario: Optional unhealthy provider does not affect readiness + Given a health service with one healthy required and one unhealthy optional provider + When health is checked + Then the overall health should be "unhealthy" + But readiness should be "healthy" + + Scenario: Provider panic is recovered gracefully + Given a health service with a provider that panics + When health is checked + Then the panicking provider should report "unhealthy" + And other providers should still be checked + + Scenario: Temporary error produces degraded status + Given a health service with a provider returning a temporary error + When health is checked + Then the provider status should be "degraded" + + Scenario: Cache returns previous result within TTL + Given a health service with a 100ms cache TTL + And a healthy provider + When health is checked twice within 50ms + Then the provider should only be called once + + Scenario: Force refresh bypasses cache + Given a health service with cached results + When health is checked with force refresh + Then the provider should be called again + + Scenario: Status change emits event + Given a health service with a provider that transitions from healthy to unhealthy + When health is checked after the transition + Then a health status changed event should be emitted diff --git a/features/reload_contract.feature b/features/reload_contract.feature new file mode 100644 index 00000000..4c3388ec --- /dev/null +++ b/features/reload_contract.feature @@ -0,0 +1,38 @@ +Feature: Dynamic Reload Contract + Modules implementing Reloadable must follow these behavioral contracts. + + Scenario: Successful reload applies changes to all reloadable modules + Given a reload orchestrator with 3 reloadable modules + When a reload is requested with configuration changes + Then all 3 modules should receive the changes + And a reload completed event should be emitted + + Scenario: Module refusing reload is skipped + Given a reload orchestrator with a module that cannot reload + When a reload is requested + Then the non-reloadable module should be skipped + And other modules should still be reloaded + + Scenario: Partial failure triggers rollback + Given a reload orchestrator with 3 modules where the second fails + When a reload is requested + Then the first module should be rolled back + And a reload failed event should be emitted + + Scenario: Circuit breaker activates after repeated failures + Given a reload orchestrator with a failing module + When 3 consecutive reloads fail + Then subsequent reload requests should be rejected + And the circuit breaker should eventually reset + + Scenario: Empty diff produces noop event + Given a reload orchestrator with reloadable modules + When a reload is requested with no changes + Then a reload noop event should be emitted + And no modules should be called + + Scenario: Concurrent reload requests are serialized + Given a reload orchestrator with reloadable modules + When 10 reload requests are submitted concurrently + Then all requests should be processed + And no race conditions should occur diff --git a/feeder_priority_test.go b/feeder_priority_test.go index 54417858..dfb47991 100644 --- a/feeder_priority_test.go +++ b/feeder_priority_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestFeederPriorityBasic tests the basic priority functionality @@ -344,7 +344,7 @@ func TestAffixedEnvFeederPriority(t *testing.T) { feeder := feeders.NewAffixedEnvFeeder("PREFIX_", "").WithPriority(100) // Verify priority was set - prioritized, ok := interface{}(feeder).(PrioritizedFeeder) + prioritized, ok := any(feeder).(PrioritizedFeeder) if !ok { t.Fatal("AffixedEnvFeeder does not implement PrioritizedFeeder interface") } @@ -413,7 +413,7 @@ func TestTenantAffixedEnvFeederPriority(t *testing.T) { tenantFeeder.SetPrefixFunc("tenant1") // Test priority was set correctly - prioritized, ok := interface{}(tenantFeeder).(PrioritizedFeeder) + prioritized, ok := any(tenantFeeder).(PrioritizedFeeder) if !ok { t.Fatal("TenantAffixedEnvFeeder does not implement PrioritizedFeeder interface") } diff --git a/feeders/yaml.go b/feeders/yaml.go index 85e2280a..1e42e11e 100644 --- a/feeders/yaml.go +++ b/feeders/yaml.go @@ -11,6 +11,16 @@ import ( "gopkg.in/yaml.v3" ) +// wrapDebugLogger returns a func(string) that calls the logger's Debug method. +// This indirection avoids go vet false positives about non-constant format strings +// passed to Debug(msg string, args ...any) interface methods. +// +//go:noinline +func wrapDebugLogger(logger interface{ Debug(msg string, args ...any) }) func(string) { + debug := logger.Debug + return func(msg string) { debug(msg) } +} + // parseYAMLTag parses a YAML struct tag and returns the field name and options func parseYAMLTag(tag string) (fieldName string, options []string) { if tag == "" { @@ -46,9 +56,7 @@ func getFieldNameFromTag(fieldType *reflect.StructField) (string, bool) { type YamlFeeder struct { Path string verboseDebug bool - logger interface { - Debug(msg string, args ...any) - } + debugFn func(string) fieldTracker FieldTracker priority int } @@ -58,7 +66,7 @@ func NewYamlFeeder(filePath string) *YamlFeeder { return &YamlFeeder{ Path: filePath, verboseDebug: false, - logger: nil, + debugFn: nil, fieldTracker: nil, priority: 0, // Default priority } @@ -80,9 +88,13 @@ func (y *YamlFeeder) Priority() int { // SetVerboseDebug enables or disables verbose debug logging func (y *YamlFeeder) SetVerboseDebug(enabled bool, logger interface{ Debug(msg string, args ...any) }) { y.verboseDebug = enabled - y.logger = logger + if logger != nil { + y.debugFn = wrapDebugLogger(logger) + } else { + y.debugFn = nil + } if enabled && logger != nil { - y.logger.Debug("Verbose YAML feeder debugging enabled") + logger.Debug("Verbose YAML feeder debugging enabled") } } @@ -91,21 +103,39 @@ func (y *YamlFeeder) SetFieldTracker(tracker FieldTracker) { y.fieldTracker = tracker } +// debugLog logs a debug message with key-value pairs when verbose debugging is enabled. +// Key-value pairs are formatted into the message string to avoid go vet printf false positives +// on the Debug(msg string, args ...any) interface method signature. +func (y *YamlFeeder) debugLog(msg string, keysAndValues ...any) { + if !y.verboseDebug || y.debugFn == nil { + return + } + if len(keysAndValues) == 0 { + y.debugFn(msg) + return + } + var b strings.Builder + b.WriteString(msg) + for i := 0; i+1 < len(keysAndValues); i += 2 { + fmt.Fprintf(&b, " %v=%v", keysAndValues[i], keysAndValues[i+1]) + } + if len(keysAndValues)%2 != 0 { + fmt.Fprintf(&b, " %v", keysAndValues[len(keysAndValues)-1]) + } + y.debugFn(b.String()) +} + // Feed reads the YAML file and populates the provided structure func (y *YamlFeeder) Feed(structure interface{}) error { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Starting feed process", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) - } + y.debugLog("YamlFeeder: Starting feed process", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) // Always use custom parsing logic for consistency err := y.feedWithTracking(structure) - if y.verboseDebug && y.logger != nil { - if err != nil { - y.logger.Debug("YamlFeeder: Feed completed with error", "filePath", y.Path, "error", err) - } else { - y.logger.Debug("YamlFeeder: Feed completed successfully", "filePath", y.Path) - } + if err != nil { + y.debugLog("YamlFeeder: Feed completed with error", "filePath", y.Path, "error", err) + } else { + y.debugLog("YamlFeeder: Feed completed successfully", "filePath", y.Path) } if err != nil { return fmt.Errorf("yaml feed error: %w", err) @@ -115,68 +145,50 @@ func (y *YamlFeeder) Feed(structure interface{}) error { // FeedKey reads a YAML file and extracts a specific key func (y *YamlFeeder) FeedKey(key string, target interface{}) error { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Starting FeedKey process", "filePath", y.Path, "key", key, "targetType", reflect.TypeOf(target)) - } + y.debugLog("YamlFeeder: Starting FeedKey process", "filePath", y.Path, "key", key, "targetType", reflect.TypeOf(target)) // Create a temporary map to hold all YAML data var allData map[interface{}]interface{} // Use the embedded Yaml feeder to read the file if err := y.Feed(&allData); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) - } + y.debugLog("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) return fmt.Errorf("failed to read YAML: %w", err) } // Look for the specific key value, exists := allData[key] if !exists { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Key not found in YAML file", "filePath", y.Path, "key", key) - } + y.debugLog("YamlFeeder: Key not found in YAML file", "filePath", y.Path, "key", key) return nil } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found key in YAML file", "filePath", y.Path, "key", key, "valueType", reflect.TypeOf(value)) - } + y.debugLog("YamlFeeder: Found key in YAML file", "filePath", y.Path, "key", key, "valueType", reflect.TypeOf(value)) // Remarshal and unmarshal to handle type conversions valueBytes, err := yaml.Marshal(value) if err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to marshal value", "filePath", y.Path, "key", key, "error", err) - } + y.debugLog("YamlFeeder: Failed to marshal value", "filePath", y.Path, "key", key, "error", err) return fmt.Errorf("failed to marshal value: %w", err) } if err = yaml.Unmarshal(valueBytes, target); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to unmarshal value to target", "filePath", y.Path, "key", key, "error", err) - } + y.debugLog("YamlFeeder: Failed to unmarshal value to target", "filePath", y.Path, "key", key, "error", err) return fmt.Errorf("failed to unmarshal value to target: %w", err) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: FeedKey completed successfully", "filePath", y.Path, "key", key) - } + y.debugLog("YamlFeeder: FeedKey completed successfully", "filePath", y.Path, "key", key) return nil } // feedWithTracking processes YAML data with field tracking support func (y *YamlFeeder) feedWithTracking(structure interface{}) error { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Starting feedWithTracking", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) - } + y.debugLog("YamlFeeder: Starting feedWithTracking", "filePath", y.Path, "structureType", reflect.TypeOf(structure)) // Read YAML file content, err := os.ReadFile(y.Path) if err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) - } + y.debugLog("YamlFeeder: Failed to read YAML file", "filePath", y.Path, "error", err) return fmt.Errorf("failed to read YAML file: %w", err) } @@ -184,9 +196,7 @@ func (y *YamlFeeder) feedWithTracking(structure interface{}) error { structValue := reflect.ValueOf(structure) if structValue.Kind() != reflect.Ptr || structValue.Elem().Kind() != reflect.Struct { // Not a struct pointer, fall back to standard YAML unmarshaling - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Not a struct pointer, using standard YAML unmarshaling", "structureType", reflect.TypeOf(structure)) - } + y.debugLog("YamlFeeder: Not a struct pointer, using standard YAML unmarshaling", "structureType", reflect.TypeOf(structure)) if err := yaml.Unmarshal(content, structure); err != nil { return fmt.Errorf("failed to unmarshal YAML data: %w", err) } @@ -196,9 +206,7 @@ func (y *YamlFeeder) feedWithTracking(structure interface{}) error { // Parse YAML content data := make(map[string]interface{}) if err := yaml.Unmarshal(content, &data); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to parse YAML content", "filePath", y.Path, "error", err) - } + y.debugLog("YamlFeeder: Failed to parse YAML content", "filePath", y.Path, "error", err) return fmt.Errorf("failed to parse YAML content: %w", err) } @@ -210,9 +218,7 @@ func (y *YamlFeeder) feedWithTracking(structure interface{}) error { func (y *YamlFeeder) processStructFields(rv reflect.Value, data map[string]interface{}, parentPath string) error { structType := rv.Type() - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing struct fields", "structType", structType, "numFields", rv.NumField(), "parentPath", parentPath) - } + y.debugLog("YamlFeeder: Processing struct fields", "structType", structType, "numFields", rv.NumField(), "parentPath", parentPath) for i := 0; i < rv.NumField(); i++ { field := rv.Field(i) @@ -224,14 +230,10 @@ func (y *YamlFeeder) processStructFields(rv reflect.Value, data map[string]inter fieldPath = parentPath + "." + fieldType.Name } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing field", "fieldName", fieldType.Name, "fieldType", fieldType.Type, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Processing field", "fieldName", fieldType.Name, "fieldType", fieldType.Type, "fieldPath", fieldPath) if err := y.processField(field, &fieldType, data, fieldPath); err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Field processing failed", "fieldName", fieldType.Name, "error", err) - } + y.debugLog("YamlFeeder: Field processing failed", "fieldName", fieldType.Name, "error", err) return fmt.Errorf("error in field '%s': %w", fieldType.Name, err) } } @@ -260,9 +262,7 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct return y.setArrayFromYAML(field, fieldName, data, fieldType.Name, fieldPath) } case reflect.Map: - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing map field", "fieldName", fieldType.Name, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Processing map field", "fieldName", fieldType.Name, "fieldPath", fieldPath) if hasYAMLTag { // Look for map data using the parsed field name @@ -270,20 +270,14 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct if mapDataTyped, ok := mapData.(map[string]interface{}); ok { return y.setMapFromYaml(field, mapDataTyped, fieldType.Name, fieldPath) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map YAML data is not a map[string]interface{}", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(mapData)) - } + y.debugLog("YamlFeeder: Map YAML data is not a map[string]interface{}", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(mapData)) } } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) - } + y.debugLog("YamlFeeder: Map YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) } } case reflect.Struct: - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Processing nested struct", "fieldName", fieldType.Name, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Processing nested struct", "fieldName", fieldType.Name, "fieldPath", fieldPath) if hasYAMLTag { // Look for nested data using the parsed field name @@ -291,14 +285,10 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct if nestedMap, ok := nestedData.(map[string]interface{}); ok { return y.processStructFields(field, nestedMap, fieldPath) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Nested YAML data is not a map", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(nestedData)) - } + y.debugLog("YamlFeeder: Nested YAML data is not a map", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "dataType", reflect.TypeOf(nestedData)) } } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Nested YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) - } + y.debugLog("YamlFeeder: Nested YAML data not found", "fieldName", fieldType.Name, "parsedFieldName", fieldName) } } else { // No yaml tag, use the same data map @@ -310,23 +300,17 @@ func (y *YamlFeeder) processField(field reflect.Value, fieldType *reflect.Struct reflect.Chan, reflect.Func, reflect.Interface, reflect.String, reflect.UnsafePointer: // Check for yaml tag for primitive types and other non-struct types if hasYAMLTag { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) return y.setFieldFromYaml(field, fieldName, data, fieldType.Name, fieldPath) - } else if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) } + y.debugLog("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) default: // Check for yaml tag for primitive types and other non-struct types if hasYAMLTag { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Found yaml tag", "fieldName", fieldType.Name, "parsedFieldName", fieldName, "fieldPath", fieldPath) return y.setFieldFromYaml(field, fieldName, data, fieldType.Name, fieldPath) - } else if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) } + y.debugLog("YamlFeeder: No yaml tag found", "fieldName", fieldType.Name, "fieldPath", fieldPath) } return nil @@ -602,18 +586,14 @@ func (y *YamlFeeder) setFieldFromYaml(field reflect.Value, yamlTag string, data if value, exists := data[yamlTag]; exists { foundValue = value foundKey = yamlTag - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Found YAML value", "fieldName", fieldName, "yamlKey", yamlTag, "value", value, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Found YAML value", "fieldName", fieldName, "yamlKey", yamlTag, "value", value, "fieldPath", fieldPath) } if foundValue != nil { // Set the field value err := y.setFieldValue(field, foundValue) if err != nil { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Failed to set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "error", err, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Failed to set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "error", err, "fieldPath", fieldPath) return err } @@ -634,9 +614,7 @@ func (y *YamlFeeder) setFieldFromYaml(field reflect.Value, yamlTag string, data y.fieldTracker.RecordFieldPopulation(fp) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Successfully set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: Successfully set field value", "fieldName", fieldName, "yamlKey", yamlTag, "value", foundValue, "fieldPath", fieldPath) } else { // Record that we searched but didn't find if y.fieldTracker != nil { @@ -655,9 +633,7 @@ func (y *YamlFeeder) setFieldFromYaml(field reflect.Value, yamlTag string, data y.fieldTracker.RecordFieldPopulation(fp) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: YAML value not found", "fieldName", fieldName, "yamlKey", yamlTag, "fieldPath", fieldPath) - } + y.debugLog("YamlFeeder: YAML value not found", "fieldName", fieldName, "yamlKey", yamlTag, "fieldPath", fieldPath) } return nil @@ -673,9 +649,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int keyType := mapType.Key() valueType := mapType.Elem() - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Setting map from YAML", "fieldName", fieldName, "mapType", mapType, "keyType", keyType, "valueType", valueType) - } + y.debugLog("YamlFeeder: Setting map from YAML", "fieldName", fieldName, "mapType", mapType, "keyType", keyType, "valueType", valueType) // Create a new map newMap := reflect.MakeMap(mapType) @@ -698,9 +672,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int keyValue := reflect.ValueOf(key) newMap.SetMapIndex(keyValue, structValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) - } + y.debugLog("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) } } case reflect.Ptr: @@ -723,13 +695,9 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int keyValue := reflect.ValueOf(key) newMap.SetMapIndex(keyValue, ptrValue) - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Successfully processed pointer to struct map entry", "key", key, "structType", elemType) - } + y.debugLog("YamlFeeder: Successfully processed pointer to struct map entry", "key", key, "structType", elemType) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) - } + y.debugLog("YamlFeeder: Map entry is not a map", "key", key, "valueType", reflect.TypeOf(value)) } } } else { @@ -746,9 +714,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int ptrValue.Elem().Set(convertedValue) newMap.SetMapIndex(keyValue, ptrValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Cannot convert map value for pointer type", "key", key, "valueType", valueReflect.Type(), "targetType", elemType) - } + y.debugLog("YamlFeeder: Cannot convert map value for pointer type", "key", key, "valueType", valueReflect.Type(), "targetType", elemType) } } } @@ -766,9 +732,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int convertedValue := valueReflect.Convert(valueType) newMap.SetMapIndex(keyValue, convertedValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) - } + y.debugLog("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) } } default: @@ -781,9 +745,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int convertedValue := valueReflect.Convert(valueType) newMap.SetMapIndex(keyValue, convertedValue) } else { - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) - } + y.debugLog("YamlFeeder: Cannot convert map value", "key", key, "valueType", valueReflect.Type(), "targetType", valueType) } } } @@ -808,9 +770,7 @@ func (y *YamlFeeder) setMapFromYaml(field reflect.Value, yamlData map[string]int y.fieldTracker.RecordFieldPopulation(fp) } - if y.verboseDebug && y.logger != nil { - y.logger.Debug("YamlFeeder: Successfully set map field", "fieldName", fieldName, "mapSize", newMap.Len()) - } + y.debugLog("YamlFeeder: Successfully set map field", "fieldName", fieldName, "mapSize", newMap.Len()) return nil } diff --git a/feeders/yaml_basic_test.go b/feeders/yaml_basic_test.go index f8cbb1b3..30ee0110 100644 --- a/feeders/yaml_basic_test.go +++ b/feeders/yaml_basic_test.go @@ -132,8 +132,8 @@ func TestYamlFeeder_NewYamlFeeder(t *testing.T) { if feeder.verboseDebug { t.Error("Expected verboseDebug to be false by default") } - if feeder.logger != nil { - t.Error("Expected logger to be nil by default") + if feeder.debugFn != nil { + t.Error("Expected debugFn to be nil by default") } if feeder.fieldTracker != nil { t.Error("Expected fieldTracker to be nil by default") @@ -149,8 +149,8 @@ func TestYamlFeeder_SetVerboseDebug(t *testing.T) { if !feeder.verboseDebug { t.Error("Expected verboseDebug to be true") } - if feeder.logger != logger { - t.Error("Expected logger to be set") + if feeder.debugFn == nil { + t.Error("Expected debugFn to be set") } // Check that debug message was logged diff --git a/field_tracker_bridge.go b/field_tracker_bridge.go index cda896c5..e05c045b 100644 --- a/field_tracker_bridge.go +++ b/field_tracker_bridge.go @@ -1,7 +1,7 @@ package modular import ( - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // FieldTrackerBridge adapts between the main package's FieldTracker interface diff --git a/go.mod b/go.mod index 62f34741..6ae1e5fe 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ -module github.com/CrisisTextLine/modular +module github.com/GoCodeAlone/modular -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.1 require ( github.com/BurntSushi/toml v1.6.0 diff --git a/health.go b/health.go new file mode 100644 index 00000000..7344e98f --- /dev/null +++ b/health.go @@ -0,0 +1,141 @@ +package modular + +import ( + "context" + "fmt" + "time" +) + +// HealthStatus represents the health state of a component. +type HealthStatus int + +const ( + // StatusUnknown indicates the health state has not been determined. + StatusUnknown HealthStatus = iota + // StatusHealthy indicates the component is functioning normally. + StatusHealthy + // StatusDegraded indicates the component is functioning with reduced capability. + StatusDegraded + // StatusUnhealthy indicates the component is not functioning. + StatusUnhealthy +) + +// String returns the string representation of a HealthStatus. +func (s HealthStatus) String() string { + switch s { + case StatusUnknown: + return "unknown" + case StatusHealthy: + return "healthy" + case StatusDegraded: + return "degraded" + case StatusUnhealthy: + return "unhealthy" + default: + return "unknown" + } +} + +// IsHealthy returns true if the status is StatusHealthy. +func (s HealthStatus) IsHealthy() bool { + return s == StatusHealthy +} + +// HealthProvider is an interface for components that can report their health. +type HealthProvider interface { + HealthCheck(ctx context.Context) ([]HealthReport, error) +} + +// HealthReport represents the health status of a single component. +type HealthReport struct { + Module string + Component string + Status HealthStatus + Message string + CheckedAt time.Time + ObservedSince time.Time + Optional bool + Details map[string]any +} + +// AggregatedHealth represents the combined health of all providers. +type AggregatedHealth struct { + Readiness HealthStatus + Health HealthStatus + Reports []HealthReport + GeneratedAt time.Time +} + +// forceHealthRefreshKeyType is an unexported type for context key safety. +type forceHealthRefreshKeyType struct{} + +// ForceHealthRefreshKey is the context key used to force a health refresh, +// bypassing the cache. Usage: context.WithValue(ctx, modular.ForceHealthRefreshKey, true) +var ForceHealthRefreshKey = forceHealthRefreshKeyType{} + +// simpleHealthProvider adapts a function into a HealthProvider. +type simpleHealthProvider struct { + module string + component string + fn func(ctx context.Context) (HealthStatus, string, error) +} + +// NewSimpleHealthProvider creates a HealthProvider from a function that returns +// a status, message, and error. +func NewSimpleHealthProvider(module, component string, fn func(ctx context.Context) (HealthStatus, string, error)) HealthProvider { + return &simpleHealthProvider{module: module, component: component, fn: fn} +} + +func (p *simpleHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + status, msg, err := p.fn(ctx) + report := HealthReport{ + Module: p.module, + Component: p.component, + Status: status, + Message: msg, + CheckedAt: time.Now(), + } + return []HealthReport{report}, err +} + +// staticHealthProvider returns fixed reports. +type staticHealthProvider struct { + reports []HealthReport +} + +// NewStaticHealthProvider creates a HealthProvider that always returns the given reports. +func NewStaticHealthProvider(reports ...HealthReport) HealthProvider { + return &staticHealthProvider{reports: reports} +} + +func (p *staticHealthProvider) HealthCheck(_ context.Context) ([]HealthReport, error) { + now := time.Now() + result := make([]HealthReport, len(p.reports)) + copy(result, p.reports) + for i := range result { + result[i].CheckedAt = now + } + return result, nil +} + +// compositeHealthProvider aggregates multiple providers into one. +type compositeHealthProvider struct { + providers []HealthProvider +} + +// NewCompositeHealthProvider creates a HealthProvider that delegates to multiple providers. +func NewCompositeHealthProvider(providers ...HealthProvider) HealthProvider { + return &compositeHealthProvider{providers: providers} +} + +func (p *compositeHealthProvider) HealthCheck(ctx context.Context) ([]HealthReport, error) { + var all []HealthReport + for _, provider := range p.providers { + reports, err := provider.HealthCheck(ctx) + if err != nil { + return all, fmt.Errorf("composite health check: %w", err) + } + all = append(all, reports...) + } + return all, nil +} diff --git a/health_contract_bdd_test.go b/health_contract_bdd_test.go new file mode 100644 index 00000000..9e6278c1 --- /dev/null +++ b/health_contract_bdd_test.go @@ -0,0 +1,456 @@ +package modular + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/cucumber/godog" +) + +// Static errors for health contract BDD tests. +var ( + errExpectedOverallHealthy = errors.New("expected overall status to be healthy") + errExpectedOverallUnhealthy = errors.New("expected overall status to be unhealthy") + errExpectedReadinessHealthy = errors.New("expected readiness to be healthy") + errExpectedReadinessUnhealthy = errors.New("expected readiness to be unhealthy") + errExpectedPanicUnhealthy = errors.New("expected panicking provider to report unhealthy") + errExpectedOtherProvidersChecked = errors.New("expected other providers to still be checked") + errExpectedDegradedStatus = errors.New("expected provider status to be degraded") + errExpectedSingleCall = errors.New("expected provider to be called only once") + errExpectedRefreshCall = errors.New("expected provider to be called again on refresh") + errExpectedStatusChangedEvent = errors.New("expected health status changed event") +) + +// healthBDDProvider is a configurable mock HealthProvider for BDD tests. +type healthBDDProvider struct { + reports []HealthReport + err error + callCount atomic.Int32 + panicMsg string + mu sync.Mutex +} + +func (p *healthBDDProvider) HealthCheck(_ context.Context) ([]HealthReport, error) { + p.callCount.Add(1) + if p.panicMsg != "" { + panic(p.panicMsg) + } + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + return nil, p.err + } + reports := make([]HealthReport, len(p.reports)) + copy(reports, p.reports) + for i := range reports { + reports[i].CheckedAt = time.Now() + } + return reports, nil +} + +func (p *healthBDDProvider) setReports(reports []HealthReport) { + p.mu.Lock() + defer p.mu.Unlock() + p.reports = reports +} + +// bddTemporaryError implements the Temporary() bool interface for degraded status. +type bddTemporaryError struct { + msg string +} + +func (e *bddTemporaryError) Error() string { return e.msg } +func (e *bddTemporaryError) Temporary() bool { return true } + +// healthBDDSubject captures events for BDD health contract tests. +type healthBDDSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *healthBDDSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *healthBDDSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *healthBDDSubject) GetObservers() []ObserverInfo { return nil } +func (s *healthBDDSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} + +func (s *healthBDDSubject) hasEventType(eventType string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, e := range s.events { + if e.Type() == eventType { + return true + } + } + return false +} + +func (s *healthBDDSubject) reset() { + s.mu.Lock() + s.events = nil + s.mu.Unlock() +} + +// HealthBDDContext holds state for health contract BDD scenarios. +type HealthBDDContext struct { + service *AggregateHealthService + subject *healthBDDSubject + providers map[string]*healthBDDProvider + result *AggregatedHealth + checkErr error +} + +func (hc *HealthBDDContext) reset() { + hc.subject = &healthBDDSubject{} + hc.providers = make(map[string]*healthBDDProvider) + hc.service = nil + hc.result = nil + hc.checkErr = nil +} + +func (hc *HealthBDDContext) ensureService() { + if hc.service == nil { + hc.service = NewAggregateHealthService( + WithSubject(hc.subject), + WithCacheTTL(250*time.Millisecond), + ) + } +} + +// Step definitions + +func (hc *HealthBDDContext) aHealthServiceWithOneHealthyProvider() error { + hc.ensureService() + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "healthy-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["healthy"] = p + hc.service.AddProvider("healthy", p) + return nil +} + +func (hc *HealthBDDContext) healthIsChecked() error { + hc.result, hc.checkErr = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) theOverallStatusShouldBe(expected string) error { + if hc.result.Health.String() != expected { + if expected == "healthy" { + return errExpectedOverallHealthy + } + return errExpectedOverallUnhealthy + } + return nil +} + +func (hc *HealthBDDContext) readinessShouldBe(expected string) error { + if hc.result.Readiness.String() != expected { + if expected == "healthy" { + return errExpectedReadinessHealthy + } + return errExpectedReadinessUnhealthy + } + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithOneHealthyAndOneUnhealthyProvider() error { + hc.ensureService() + healthy := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "healthy-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + unhealthy := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "unhealthy-mod", + Component: "main", + Status: StatusUnhealthy, + Message: "down", + }}, + } + hc.providers["healthy"] = healthy + hc.providers["unhealthy"] = unhealthy + hc.service.AddProvider("healthy", healthy) + hc.service.AddProvider("unhealthy", unhealthy) + return nil +} + +func (hc *HealthBDDContext) theOverallHealthShouldBe(expected string) error { + return hc.theOverallStatusShouldBe(expected) +} + +func (hc *HealthBDDContext) aHealthServiceWithOneHealthyRequiredAndOneUnhealthyOptionalProvider() error { + hc.ensureService() + required := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "required-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + Optional: false, + }}, + } + optional := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "optional-mod", + Component: "aux", + Status: StatusUnhealthy, + Message: "not critical", + Optional: true, + }}, + } + hc.providers["required"] = required + hc.providers["optional"] = optional + hc.service.AddProvider("required", required) + hc.service.AddProvider("optional", optional) + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithAProviderThatPanics() error { + hc.ensureService() + panicker := &healthBDDProvider{ + panicMsg: "something went terribly wrong", + } + stable := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "stable-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["panicker"] = panicker + hc.providers["stable"] = stable + hc.service.AddProvider("panicker", panicker) + hc.service.AddProvider("stable", stable) + return nil +} + +func (hc *HealthBDDContext) thePanickingProviderShouldReport(expected string) error { + for _, r := range hc.result.Reports { + if r.Component == "panic-recovery" { + if r.Status.String() != expected { + return errExpectedPanicUnhealthy + } + return nil + } + } + return errExpectedPanicUnhealthy +} + +func (hc *HealthBDDContext) otherProvidersShouldStillBeChecked() error { + for _, r := range hc.result.Reports { + if r.Module == "stable-mod" { + return nil + } + } + return errExpectedOtherProvidersChecked +} + +func (hc *HealthBDDContext) aHealthServiceWithAProviderReturningATemporaryError() error { + hc.ensureService() + p := &healthBDDProvider{ + err: &bddTemporaryError{msg: "transient issue"}, + } + hc.providers["temp-err"] = p + hc.service.AddProvider("temp-err", p) + return nil +} + +func (hc *HealthBDDContext) theProviderStatusShouldBe(expected string) error { + for _, r := range hc.result.Reports { + if r.Status.String() == expected { + return nil + } + } + return errExpectedDegradedStatus +} + +func (hc *HealthBDDContext) aHealthServiceWithA100msCacheTTL() error { + hc.service = NewAggregateHealthService( + WithSubject(hc.subject), + WithCacheTTL(100*time.Millisecond), + ) + return nil +} + +func (hc *HealthBDDContext) aHealthyProvider() error { + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "cached-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["cached"] = p + hc.service.AddProvider("cached", p) + return nil +} + +func (hc *HealthBDDContext) healthIsCheckedTwiceWithin50ms() error { + hc.result, hc.checkErr = hc.service.Check(context.Background()) + if hc.checkErr != nil { + return hc.checkErr + } + // Second check within cache TTL + time.Sleep(10 * time.Millisecond) + hc.result, hc.checkErr = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) theProviderShouldOnlyBeCalledOnce() error { + p := hc.providers["cached"] + if p.callCount.Load() != 1 { + return errExpectedSingleCall + } + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithCachedResults() error { + hc.service = NewAggregateHealthService( + WithSubject(hc.subject), + WithCacheTTL(10*time.Second), + ) + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "refresh-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["refresh"] = p + hc.service.AddProvider("refresh", p) + // Prime the cache + _, _ = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) healthIsCheckedWithForceRefresh() error { + ctx := context.WithValue(context.Background(), ForceHealthRefreshKey, true) + hc.result, hc.checkErr = hc.service.Check(ctx) + return nil +} + +func (hc *HealthBDDContext) theProviderShouldBeCalledAgain() error { + p := hc.providers["refresh"] + if p.callCount.Load() < 2 { + return errExpectedRefreshCall + } + return nil +} + +func (hc *HealthBDDContext) aHealthServiceWithAProviderThatTransitionsFromHealthyToUnhealthy() error { + hc.ensureService() + p := &healthBDDProvider{ + reports: []HealthReport{{ + Module: "transitioning-mod", + Component: "main", + Status: StatusHealthy, + Message: "ok", + }}, + } + hc.providers["transitioning"] = p + hc.service.AddProvider("transitioning", p) + + // Do initial check to establish healthy baseline, then invalidate cache. + _, _ = hc.service.Check(context.Background()) + hc.service.invalidateCache() + + // Transition to unhealthy. + p.setReports([]HealthReport{{ + Module: "transitioning-mod", + Component: "main", + Status: StatusUnhealthy, + Message: "went down", + }}) + return nil +} + +func (hc *HealthBDDContext) healthIsCheckedAfterTheTransition() error { + hc.result, hc.checkErr = hc.service.Check(context.Background()) + return nil +} + +func (hc *HealthBDDContext) aHealthStatusChangedEventShouldBeEmitted() error { + if hc.subject.hasEventType(EventTypeHealthStatusChanged) { + return nil + } + return errExpectedStatusChangedEvent +} + +// InitializeHealthContractScenario wires up all health contract BDD steps. +func InitializeHealthContractScenario(ctx *godog.ScenarioContext) { + hc := &HealthBDDContext{} + + ctx.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) { + hc.reset() + return ctx, nil + }) + + ctx.Step(`^a health service with one healthy provider$`, hc.aHealthServiceWithOneHealthyProvider) + ctx.Step(`^health is checked$`, hc.healthIsChecked) + ctx.Step(`^the overall status should be "([^"]*)"$`, hc.theOverallStatusShouldBe) + ctx.Step(`^readiness should be "([^"]*)"$`, hc.readinessShouldBe) + + ctx.Step(`^a health service with one healthy and one unhealthy provider$`, hc.aHealthServiceWithOneHealthyAndOneUnhealthyProvider) + ctx.Step(`^the overall health should be "([^"]*)"$`, hc.theOverallHealthShouldBe) + + ctx.Step(`^a health service with one healthy required and one unhealthy optional provider$`, hc.aHealthServiceWithOneHealthyRequiredAndOneUnhealthyOptionalProvider) + + ctx.Step(`^a health service with a provider that panics$`, hc.aHealthServiceWithAProviderThatPanics) + ctx.Step(`^the panicking provider should report "([^"]*)"$`, hc.thePanickingProviderShouldReport) + ctx.Step(`^other providers should still be checked$`, hc.otherProvidersShouldStillBeChecked) + + ctx.Step(`^a health service with a provider returning a temporary error$`, hc.aHealthServiceWithAProviderReturningATemporaryError) + ctx.Step(`^the provider status should be "([^"]*)"$`, hc.theProviderStatusShouldBe) + + ctx.Step(`^a health service with a 100ms cache TTL$`, hc.aHealthServiceWithA100msCacheTTL) + ctx.Step(`^a healthy provider$`, hc.aHealthyProvider) + ctx.Step(`^health is checked twice within 50ms$`, hc.healthIsCheckedTwiceWithin50ms) + ctx.Step(`^the provider should only be called once$`, hc.theProviderShouldOnlyBeCalledOnce) + + ctx.Step(`^a health service with cached results$`, hc.aHealthServiceWithCachedResults) + ctx.Step(`^health is checked with force refresh$`, hc.healthIsCheckedWithForceRefresh) + ctx.Step(`^the provider should be called again$`, hc.theProviderShouldBeCalledAgain) + + ctx.Step(`^a health service with a provider that transitions from healthy to unhealthy$`, hc.aHealthServiceWithAProviderThatTransitionsFromHealthyToUnhealthy) + ctx.Step(`^health is checked after the transition$`, hc.healthIsCheckedAfterTheTransition) + ctx.Step(`^a health status changed event should be emitted$`, hc.aHealthStatusChangedEventShouldBeEmitted) +} + +// TestHealthContractBDD runs the BDD tests for the health contract. +func TestHealthContractBDD(t *testing.T) { + suite := godog.TestSuite{ + ScenarioInitializer: InitializeHealthContractScenario, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features/health_contract.feature"}, + TestingT: t, + Strict: true, + }, + } + + if suite.Run() != 0 { + t.Fatal("non-zero status returned, failed to run health contract feature tests") + } +} diff --git a/health_service.go b/health_service.go new file mode 100644 index 00000000..afbd4570 --- /dev/null +++ b/health_service.go @@ -0,0 +1,291 @@ +package modular + +import ( + "context" + "fmt" + "maps" + "sync" + "time" +) + +// AggregateHealthService collects health reports from registered providers +// and produces an aggregated health result with caching and event emission. +type AggregateHealthService struct { + providers map[string]HealthProvider + mu sync.RWMutex + cache *AggregatedHealth + cacheMu sync.RWMutex + cacheExpiry time.Time + cacheTTL time.Duration + lastStatus HealthStatus + subject Subject + logger Logger +} + +// HealthServiceOption configures an AggregateHealthService. +type HealthServiceOption func(*AggregateHealthService) + +// WithCacheTTL sets the cache time-to-live for health check results. +func WithCacheTTL(d time.Duration) HealthServiceOption { + return func(s *AggregateHealthService) { + s.cacheTTL = d + } +} + +// WithSubject sets the event subject for health event emission. +func WithSubject(sub Subject) HealthServiceOption { + return func(s *AggregateHealthService) { + s.subject = sub + } +} + +// WithHealthLogger sets the structured logger for the health service. +func WithHealthLogger(l Logger) HealthServiceOption { + return func(s *AggregateHealthService) { + s.logger = l + } +} + +// NewAggregateHealthService creates a new AggregateHealthService with the given options. +func NewAggregateHealthService(opts ...HealthServiceOption) *AggregateHealthService { + svc := &AggregateHealthService{ + providers: make(map[string]HealthProvider), + cacheTTL: 250 * time.Millisecond, + lastStatus: StatusUnknown, + } + for _, opt := range opts { + opt(svc) + } + return svc +} + +// AddProvider registers a named health provider and invalidates the cache. +func (s *AggregateHealthService) AddProvider(name string, provider HealthProvider) { + s.mu.Lock() + s.providers[name] = provider + s.mu.Unlock() + s.invalidateCache() +} + +// RemoveProvider removes a named health provider and invalidates the cache. +func (s *AggregateHealthService) RemoveProvider(name string) { + s.mu.Lock() + delete(s.providers, name) + s.mu.Unlock() + s.invalidateCache() +} + +func (s *AggregateHealthService) invalidateCache() { + s.cacheMu.Lock() + s.cache = nil + s.cacheExpiry = time.Time{} + s.cacheMu.Unlock() +} + +// providerResult is used to collect results from concurrent provider checks. +type providerResult struct { + reports []HealthReport + err error + name string +} + +// Check evaluates all registered providers and returns an aggregated health result. +// Results are cached for the configured TTL unless ForceHealthRefreshKey is set in the context. +// The returned AggregatedHealth is a deep copy and safe to mutate. +func (s *AggregateHealthService) Check(ctx context.Context) (*AggregatedHealth, error) { + // Check cache validity + forceRefresh, _ := ctx.Value(ForceHealthRefreshKey).(bool) + if !forceRefresh { + s.cacheMu.RLock() + if s.cache != nil && time.Now().Before(s.cacheExpiry) { + copied := s.deepCopyAggregated(s.cache) + s.cacheMu.RUnlock() + return copied, nil + } + s.cacheMu.RUnlock() + } + + // Snapshot providers under read lock + s.mu.RLock() + providers := make(map[string]HealthProvider, len(s.providers)) + maps.Copy(providers, s.providers) + s.mu.RUnlock() + + // Fan-out to all providers + ch := make(chan providerResult, len(providers)) + for name, provider := range providers { + go func(name string, provider HealthProvider) { + result := providerResult{name: name} + defer func() { + if r := recover(); r != nil { + result.reports = []HealthReport{{ + Module: name, + Component: "panic-recovery", + Status: StatusUnhealthy, + Message: fmt.Sprintf("provider panicked: %v", r), + CheckedAt: time.Now(), + }} + result.err = nil + ch <- result + } + }() + reports, err := provider.HealthCheck(ctx) + result.reports = reports + result.err = err + ch <- result + }(name, provider) + } + + // Collect results + var allReports []HealthReport + readiness := StatusHealthy + health := StatusHealthy + + for range len(providers) { + var result providerResult + select { + case result = <-ch: + case <-ctx.Done(): + return nil, fmt.Errorf("health check interrupted: %w", ctx.Err()) + } + + if result.err != nil { + // Check if error is temporary + status := StatusUnhealthy + if te, ok := result.err.(interface{ Temporary() bool }); ok && te.Temporary() { + status = StatusDegraded + } + // Add error report + allReports = append(allReports, HealthReport{ + Module: result.name, + Component: "error", + Status: status, + Message: result.err.Error(), + CheckedAt: time.Now(), + }) + readiness = worstStatus(readiness, status) + health = worstStatus(health, status) + continue + } + + for _, report := range result.reports { + allReports = append(allReports, report) + health = worstStatus(health, report.Status) + if !report.Optional { + readiness = worstStatus(readiness, report.Status) + } + } + } + + aggregated := &AggregatedHealth{ + Readiness: readiness, + Health: health, + Reports: allReports, + GeneratedAt: time.Now(), + } + + // Cache result + s.cacheMu.Lock() + s.cache = aggregated + s.cacheExpiry = time.Now().Add(s.cacheTTL) + s.cacheMu.Unlock() + + // Emit events + s.emitHealthEvaluated(ctx, aggregated) + + s.cacheMu.Lock() + previousStatus := s.lastStatus + s.lastStatus = aggregated.Health + s.cacheMu.Unlock() + + if previousStatus != aggregated.Health { + s.emitHealthStatusChanged(ctx, previousStatus, aggregated.Health) + } + + return s.deepCopyAggregated(aggregated), nil +} + +// deepCopyAggregated returns a deep copy of an AggregatedHealth, including +// reports and their Details maps, so callers cannot mutate cached state. +func (s *AggregateHealthService) deepCopyAggregated(src *AggregatedHealth) *AggregatedHealth { + if src == nil { + return nil + } + dst := &AggregatedHealth{ + Readiness: src.Readiness, + Health: src.Health, + GeneratedAt: src.GeneratedAt, + Reports: make([]HealthReport, len(src.Reports)), + } + for i, r := range src.Reports { + dst.Reports[i] = r + if r.Details != nil { + dst.Reports[i].Details = make(map[string]any, len(r.Details)) + maps.Copy(dst.Reports[i].Details, r.Details) + } + } + return dst +} + +func (s *AggregateHealthService) emitHealthEvaluated(ctx context.Context, agg *AggregatedHealth) { + if s.subject == nil { + return + } + event := NewCloudEvent(EventTypeHealthEvaluated, "modular/health-service", map[string]any{ + "readiness": agg.Readiness.String(), + "health": agg.Health.String(), + "report_count": len(agg.Reports), + }, nil) + if err := s.subject.NotifyObservers(ctx, event); err != nil && s.logger != nil { + s.logger.Debug("Failed to emit health evaluated event", "error", err) + } +} + +func (s *AggregateHealthService) emitHealthStatusChanged(ctx context.Context, from, to HealthStatus) { + if s.subject == nil { + return + } + event := NewCloudEvent(EventTypeHealthStatusChanged, "modular/health-service", map[string]any{ + "previous_status": from.String(), + "current_status": to.String(), + }, nil) + if err := s.subject.NotifyObservers(ctx, event); err != nil && s.logger != nil { + s.logger.Debug("Failed to emit health status changed event", "error", err) + } +} + +// worstStatus returns the worse of two health statuses. +// StatusUnknown is treated as StatusUnhealthy for aggregation purposes: +// if either status is Unknown, it is mapped to Unhealthy in the result +// so that the aggregated output consistently reflects unhealthy severity. +func worstStatus(a, b HealthStatus) HealthStatus { + ar := normalizeForAggregation(a) + br := normalizeForAggregation(b) + var winner HealthStatus + if ar >= br { + winner = a + } else { + winner = b + } + // Map Unknown → Unhealthy so aggregated health never reports "unknown". + if winner == StatusUnknown { + return StatusUnhealthy + } + return winner +} + +// normalizeForAggregation maps StatusUnknown to StatusUnhealthy severity for comparison. +func normalizeForAggregation(s HealthStatus) int { + switch s { + case StatusHealthy: + return 0 + case StatusDegraded: + return 1 + case StatusUnhealthy: + return 2 + case StatusUnknown: + return 2 // Unknown treated as Unhealthy + default: + return 2 + } +} diff --git a/health_test.go b/health_test.go new file mode 100644 index 00000000..9a200dc4 --- /dev/null +++ b/health_test.go @@ -0,0 +1,435 @@ +package modular + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +func TestHealthStatus_String(t *testing.T) { + tests := []struct { + status HealthStatus + want string + }{ + {StatusUnknown, "unknown"}, + {StatusHealthy, "healthy"}, + {StatusDegraded, "degraded"}, + {StatusUnhealthy, "unhealthy"}, + {HealthStatus(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.status.String(); got != tt.want { + t.Errorf("HealthStatus(%d).String() = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestHealthStatus_IsHealthy(t *testing.T) { + if !StatusHealthy.IsHealthy() { + t.Error("StatusHealthy.IsHealthy() should be true") + } + for _, s := range []HealthStatus{StatusUnknown, StatusDegraded, StatusUnhealthy} { + if s.IsHealthy() { + t.Errorf("%v.IsHealthy() should be false", s) + } + } +} + +func TestSimpleHealthProvider(t *testing.T) { + provider := NewSimpleHealthProvider("mymod", "db", func(_ context.Context) (HealthStatus, string, error) { + return StatusHealthy, "all good", nil + }) + reports, err := provider.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 1 { + t.Fatalf("expected 1 report, got %d", len(reports)) + } + r := reports[0] + if r.Module != "mymod" || r.Component != "db" || r.Status != StatusHealthy || r.Message != "all good" { + t.Errorf("unexpected report: %+v", r) + } + if r.CheckedAt.IsZero() { + t.Error("CheckedAt should be set") + } +} + +func TestStaticHealthProvider(t *testing.T) { + report := HealthReport{ + Module: "static", + Component: "cache", + Status: StatusDegraded, + Message: "warming up", + } + provider := NewStaticHealthProvider(report) + reports, err := provider.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 1 { + t.Fatalf("expected 1 report, got %d", len(reports)) + } + if reports[0].Status != StatusDegraded { + t.Errorf("expected degraded, got %v", reports[0].Status) + } + if reports[0].CheckedAt.IsZero() { + t.Error("CheckedAt should be set by static provider") + } +} + +func TestCompositeHealthProvider(t *testing.T) { + p1 := NewStaticHealthProvider(HealthReport{Module: "a", Component: "1", Status: StatusHealthy}) + p2 := NewStaticHealthProvider(HealthReport{Module: "b", Component: "2", Status: StatusDegraded}) + composite := NewCompositeHealthProvider(p1, p2) + + reports, err := composite.HealthCheck(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reports) != 2 { + t.Fatalf("expected 2 reports, got %d", len(reports)) + } +} + +// testSubject is a minimal Subject implementation for testing event emission. +type testSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *testSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *testSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *testSubject) GetObservers() []ObserverInfo { return nil } +func (s *testSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} +func (s *testSubject) getEvents() []cloudevents.Event { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]cloudevents.Event, len(s.events)) + copy(result, s.events) + return result +} + +func TestAggregateHealthService_SingleProvider(t *testing.T) { + svc := NewAggregateHealthService() + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, Message: "ok", + })) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusHealthy { + t.Errorf("expected healthy, got %v", result.Health) + } + if result.Readiness != StatusHealthy { + t.Errorf("expected readiness healthy, got %v", result.Readiness) + } + if len(result.Reports) != 1 { + t.Errorf("expected 1 report, got %d", len(result.Reports)) + } +} + +func TestAggregateHealthService_MultipleProviders(t *testing.T) { + svc := NewAggregateHealthService() + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + svc.AddProvider("cache", NewStaticHealthProvider(HealthReport{ + Module: "cache", Component: "redis", Status: StatusDegraded, + })) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusDegraded { + t.Errorf("expected degraded health, got %v", result.Health) + } + if result.Readiness != StatusDegraded { + t.Errorf("expected degraded readiness, got %v", result.Readiness) + } + if len(result.Reports) != 2 { + t.Errorf("expected 2 reports, got %d", len(result.Reports)) + } +} + +func TestAggregateHealthService_OptionalVsRequired(t *testing.T) { + svc := NewAggregateHealthService() + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + svc.AddProvider("metrics", NewStaticHealthProvider(HealthReport{ + Module: "metrics", Component: "export", Status: StatusUnhealthy, Optional: true, + })) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Health reflects all components (worst = unhealthy) + if result.Health != StatusUnhealthy { + t.Errorf("expected unhealthy health (includes optional), got %v", result.Health) + } + // Readiness only reflects required components (should be healthy) + if result.Readiness != StatusHealthy { + t.Errorf("expected healthy readiness (optional excluded), got %v", result.Readiness) + } +} + +func TestAggregateHealthService_CacheHit(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(1 * time.Second)) + svc.AddProvider("test", provider) + + // First call + _, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 1 { + t.Fatalf("expected 1 call, got %d", callCount) + } + + // Second call within TTL should be cached + _, err = svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 1 { + t.Errorf("expected 1 call (cached), got %d", callCount) + } +} + +func TestAggregateHealthService_CacheMiss(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(1 * time.Millisecond)) + svc.AddProvider("test", provider) + + _, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Wait for cache to expire + time.Sleep(5 * time.Millisecond) + + _, err = svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 2 { + t.Errorf("expected 2 calls after cache expiry, got %d", callCount) + } +} + +func TestAggregateHealthService_CacheInvalidation(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(10 * time.Second)) + svc.AddProvider("test", provider) + + _, _ = svc.Check(context.Background()) + if callCount != 1 { + t.Fatalf("expected 1 call, got %d", callCount) + } + + // AddProvider should invalidate cache + svc.AddProvider("another", NewStaticHealthProvider(HealthReport{ + Module: "x", Component: "y", Status: StatusHealthy, + })) + + _, _ = svc.Check(context.Background()) + if callCount != 2 { + t.Errorf("expected 2 calls after AddProvider invalidation, got %d", callCount) + } + + // RemoveProvider should also invalidate + svc.RemoveProvider("another") + _, _ = svc.Check(context.Background()) + if callCount != 3 { + t.Errorf("expected 3 calls after RemoveProvider invalidation, got %d", callCount) + } +} + +func TestAggregateHealthService_ForceRefresh(t *testing.T) { + callCount := 0 + provider := NewSimpleHealthProvider("mod", "comp", func(_ context.Context) (HealthStatus, string, error) { + callCount++ + return StatusHealthy, "ok", nil + }) + + svc := NewAggregateHealthService(WithCacheTTL(10 * time.Second)) + svc.AddProvider("test", provider) + + _, _ = svc.Check(context.Background()) + if callCount != 1 { + t.Fatalf("expected 1 call, got %d", callCount) + } + + // Force refresh bypasses cache + ctx := context.WithValue(context.Background(), ForceHealthRefreshKey, true) + _, _ = svc.Check(ctx) + if callCount != 2 { + t.Errorf("expected 2 calls after force refresh, got %d", callCount) + } +} + +func TestAggregateHealthService_PanicRecovery(t *testing.T) { + panicProvider := NewSimpleHealthProvider("panicky", "boom", func(_ context.Context) (HealthStatus, string, error) { + panic("something went wrong") + }) + + svc := NewAggregateHealthService() + svc.AddProvider("panicky", panicProvider) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusUnhealthy { + t.Errorf("expected unhealthy after panic, got %v", result.Health) + } + // Check that the panic report is present + found := false + for _, r := range result.Reports { + if r.Status == StatusUnhealthy && r.Component == "panic-recovery" { + found = true + break + } + } + if !found { + t.Error("expected panic recovery report in results") + } +} + +// temporaryError implements the Temporary() interface. +type temporaryError struct { + msg string +} + +func (e *temporaryError) Error() string { return e.msg } +func (e *temporaryError) Temporary() bool { return true } + +func TestAggregateHealthService_TemporaryError(t *testing.T) { + provider := NewSimpleHealthProvider("net", "conn", func(_ context.Context) (HealthStatus, string, error) { + return StatusUnknown, "", &temporaryError{msg: "connection timeout"} + }) + + svc := NewAggregateHealthService() + svc.AddProvider("net", provider) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusDegraded { + t.Errorf("expected degraded for temporary error, got %v", result.Health) + } +} + +func TestAggregateHealthService_PermanentError(t *testing.T) { + provider := NewSimpleHealthProvider("db", "conn", func(_ context.Context) (HealthStatus, string, error) { + return StatusUnknown, "", errors.New("connection refused") + }) + + svc := NewAggregateHealthService() + svc.AddProvider("db", provider) + + result, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Health != StatusUnhealthy { + t.Errorf("expected unhealthy for permanent error, got %v", result.Health) + } +} + +func TestAggregateHealthService_EventEmission(t *testing.T) { + sub := &testSubject{} + svc := NewAggregateHealthService(WithSubject(sub)) + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + + _, _ = svc.Check(context.Background()) + + events := sub.getEvents() + // First check: should emit evaluated + status changed (unknown -> healthy) + if len(events) < 1 { + t.Fatal("expected at least 1 event") + } + + hasEvaluated := false + hasChanged := false + for _, e := range events { + switch e.Type() { + case EventTypeHealthEvaluated: + hasEvaluated = true + case EventTypeHealthStatusChanged: + hasChanged = true + } + } + if !hasEvaluated { + t.Error("expected health evaluated event") + } + if !hasChanged { + t.Error("expected health status changed event (unknown -> healthy)") + } +} + +func TestAggregateHealthService_ConcurrentChecks(t *testing.T) { + svc := NewAggregateHealthService(WithCacheTTL(1 * time.Millisecond)) + svc.AddProvider("db", NewStaticHealthProvider(HealthReport{ + Module: "db", Component: "conn", Status: StatusHealthy, + })) + + const goroutines = 20 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + + for range goroutines { + wg.Go(func() { + result, err := svc.Check(context.Background()) + if err != nil { + errs <- err + return + } + if result == nil { + errs <- errors.New("nil result") + return + } + }) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Errorf("concurrent check error: %v", err) + } +} diff --git a/implicit_dependency_bug_test.go b/implicit_dependency_bug_test.go index cc2451bd..50e8d6ee 100644 --- a/implicit_dependency_bug_test.go +++ b/implicit_dependency_bug_test.go @@ -88,7 +88,7 @@ func TestImplicitDependencyDeterministicFix(t *testing.T) { // This test will pass once we fix the dependency resolution to be deterministic attempts := 20 - for i := 0; i < attempts; i++ { + for i := range attempts { err := runSingleImplicitDependencyTestWithFix() if err != nil { t.Fatalf("Attempt %d failed after fix: %v", i+1, err) @@ -164,7 +164,7 @@ func TestNamingGameAttempt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test multiple times to ensure deterministic behavior - for attempt := 0; attempt < 5; attempt++ { + for attempt := range 5 { err := runNamingGameTest(tt.providerName, tt.consumerName) if err != nil { t.Errorf("Attempt %d failed for %s (%s): %v", @@ -253,7 +253,7 @@ func TestServiceNamingGameAttempt(t *testing.T) { } // Test multiple times to ensure deterministic behavior - for attempt := 0; attempt < 3; attempt++ { + for attempt := range 3 { err := runServiceNamingGameTest(tt.serviceName) if err != nil { t.Errorf("Attempt %d failed for %s (%s): %v", @@ -353,7 +353,7 @@ func (m *FlakyServerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*http.Handler)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[http.Handler](), }, } } @@ -476,7 +476,7 @@ func (m *CustomServiceConsumerModule) RequiresServices() []ServiceDependency { Name: m.serviceName, Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*http.Handler)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[http.Handler](), }, } } diff --git a/instance_aware_comprehensive_regression_test.go b/instance_aware_comprehensive_regression_test.go index 5e09f5e2..6df997e9 100644 --- a/instance_aware_comprehensive_regression_test.go +++ b/instance_aware_comprehensive_regression_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestInstanceAwareComprehensiveRegressionSuite creates a comprehensive test suite @@ -347,8 +347,8 @@ func testRegressionDetectionCopyVsOriginal(t *testing.T) { // Create a "broken" version of GetInstanceConfigs that returns copies // This simulates what would happen if someone reverted the fix - brokenGetInstanceConfigs := func() map[string]interface{} { - instances := make(map[string]interface{}) + brokenGetInstanceConfigs := func() map[string]any { + instances := make(map[string]any) for name, connection := range config.Connections { // BUG: Creating a copy instead of returning pointer to original connectionCopy := *connection diff --git a/instance_aware_config.go b/instance_aware_config.go index 930236a9..36056af1 100644 --- a/instance_aware_config.go +++ b/instance_aware_config.go @@ -27,5 +27,5 @@ func (p *InstanceAwareConfigProvider) GetInstancePrefixFunc() InstancePrefixFunc // InstanceAwareConfigSupport indicates that a configuration supports instance-aware feeding type InstanceAwareConfigSupport interface { // GetInstanceConfigs returns a map of instance configurations that should be fed with instance-aware feeders - GetInstanceConfigs() map[string]interface{} + GetInstanceConfigs() map[string]any } diff --git a/instance_aware_feeding_test.go b/instance_aware_feeding_test.go index 35dd5d4a..5dd50f71 100644 --- a/instance_aware_feeding_test.go +++ b/instance_aware_feeding_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestInstanceAwareFeedingAfterYAML verifies instance-aware feeding after YAML load. @@ -280,8 +280,8 @@ func (c *TestDatabaseConfig) Validate() error { return nil } -func (c *TestDatabaseConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestDatabaseConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, connection := range c.Connections { instances[name] = connection } @@ -302,8 +302,8 @@ func (c *TestWebappConfig) Validate() error { return nil } -func (c *TestWebappConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestWebappConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, instance := range c.Instances { instances[name] = instance } @@ -433,7 +433,7 @@ func testWebappInstanceAwareFeedingResults(t *testing.T, provider ConfigProvider func splitKey(key string) []string { parts := make([]string, 0, 2) - for i := 0; i < 2; i++ { + for i := range 2 { if dotIndex := findDotIndex(key); dotIndex != -1 { if i == 0 { parts = append(parts, key[:dotIndex]) @@ -561,8 +561,8 @@ func (c *TestInstanceConfig) Validate() error { return nil } -func (c *TestInstanceConfig) GetInstanceConfigs() map[string]interface{} { - instances := make(map[string]interface{}) +func (c *TestInstanceConfig) GetInstanceConfigs() map[string]any { + instances := make(map[string]any) for name, item := range c.Items { instances[name] = item } diff --git a/interface_dependencies_test.go b/interface_dependencies_test.go index 5bf9cdc8..efbdca88 100644 --- a/interface_dependencies_test.go +++ b/interface_dependencies_test.go @@ -31,7 +31,7 @@ func TestInterfaceDependencies(t *testing.T) { app.RegisterModule(serviceProviderModule) // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -147,7 +147,7 @@ func (m *RouterConsumerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*Router)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[Router](), }, } } diff --git a/interface_matching_test.go b/interface_matching_test.go index 295e4edb..8660639d 100644 --- a/interface_matching_test.go +++ b/interface_matching_test.go @@ -30,7 +30,7 @@ func TestInterfaceMatching(t *testing.T) { app.RegisterModule(providerModule) // Resolve dependencies - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -258,7 +258,7 @@ func TestDependencyOrderWithInterfaceMatching(t *testing.T) { // With the improved dependency resolution, the provider should come before consumer // even though we registered them in the opposite order - order, err := app.resolveDependencies() + order, _, err := app.resolveDependencies() if err != nil { t.Fatalf("Failed to resolve dependencies: %v", err) } @@ -353,7 +353,7 @@ func (m *InterfaceConsumerModule) RequiresServices() []ServiceDependency { Name: "router.service", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*handleFuncService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[handleFuncService](), }, } } @@ -436,7 +436,7 @@ func (m *CustomNameConsumerModule) RequiresServices() []ServiceDependency { Name: "router", Required: true, MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*handleFuncService)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[handleFuncService](), }, } } diff --git a/issue_reproduction_test.go b/issue_reproduction_test.go index 1540a4f4..dcaf5491 100644 --- a/issue_reproduction_test.go +++ b/issue_reproduction_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestIssueReproduction demonstrates the exact scenario from the GitHub issue diff --git a/logger_decorator_assertions_bdd_test.go b/logger_decorator_assertions_bdd_test.go index 9d3b4d1b..a511d9fc 100644 --- a/logger_decorator_assertions_bdd_test.go +++ b/logger_decorator_assertions_bdd_test.go @@ -174,7 +174,7 @@ func (ctx *LoggerDecoratorBDDTestContext) theMessagesShouldHaveLevels(expectedLe func InitializeLoggerDecoratorScenario(ctx *godog.ScenarioContext) { testCtx := &LoggerDecoratorBDDTestContext{ expectedArgs: make(map[string]string), - filterCriteria: make(map[string]interface{}), + filterCriteria: make(map[string]any), levelMappings: make(map[string]string), } diff --git a/logger_decorator_base_bdd_test.go b/logger_decorator_base_bdd_test.go index ad6d90cd..55415c91 100644 --- a/logger_decorator_base_bdd_test.go +++ b/logger_decorator_base_bdd_test.go @@ -8,17 +8,13 @@ import ( // Static errors for logger decorator BDD tests var ( - errLoggerNotSet = errors.New("logger not set") - errBaseLoggerNotSet = errors.New("base logger not set") - errPrimaryLoggerNotSet = errors.New("primary logger not set") - errSecondaryLoggerNotSet = errors.New("secondary logger not set") - errDecoratedLoggerNotSet = errors.New("decorated logger not set") - errNoMessagesLogged = errors.New("no messages logged") - errUnexpectedMessageCount = errors.New("unexpected message count") - errMessageNotFound = errors.New("message not found") - errArgNotFound = errors.New("argument not found") - errUnexpectedLogLevel = errors.New("unexpected log level") - errServiceLoggerMismatch = errors.New("service logger mismatch") + errLoggerNotSet = errors.New("logger not set") + errBaseLoggerNotSet = errors.New("base logger not set") + errPrimaryLoggerNotSet = errors.New("primary logger not set") + errSecondaryLoggerNotSet = errors.New("secondary logger not set") + errDecoratedLoggerNotSet = errors.New("decorated logger not set") + errNoMessagesLogged = errors.New("no messages logged") + errServiceLoggerMismatch = errors.New("service logger mismatch") ) // LoggerDecoratorBDDTestContext holds the test context for logger decorator BDD scenarios @@ -33,7 +29,7 @@ type LoggerDecoratorBDDTestContext struct { currentLogger Logger expectedMessages []string expectedArgs map[string]string - filterCriteria map[string]interface{} + filterCriteria map[string]any levelMappings map[string]string messageCount int expectedLevels []string diff --git a/logger_test.go b/logger_test.go index ec5cf56b..34055461 100644 --- a/logger_test.go +++ b/logger_test.go @@ -9,18 +9,18 @@ type MockLogger struct { mock.Mock } -func (m *MockLogger) Debug(msg string, args ...interface{}) { +func (m *MockLogger) Debug(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Info(msg string, args ...interface{}) { +func (m *MockLogger) Info(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Warn(msg string, args ...interface{}) { +func (m *MockLogger) Warn(msg string, args ...any) { m.Called(msg, args) } -func (m *MockLogger) Error(msg string, args ...interface{}) { +func (m *MockLogger) Error(msg string, args ...any) { m.Called(msg, args) } diff --git a/metrics.go b/metrics.go new file mode 100644 index 00000000..d5e9fd07 --- /dev/null +++ b/metrics.go @@ -0,0 +1,14 @@ +package modular + +import "context" + +// ModuleMetrics holds metrics collected from a single module. +type ModuleMetrics struct { + Name string + Values map[string]float64 +} + +// MetricsProvider is an optional interface for modules that expose operational metrics. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) ModuleMetrics +} diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 00000000..06d64089 --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,74 @@ +package modular + +import ( + "context" + "testing" +) + +type metricsTestModule struct { + name string +} + +func (m *metricsTestModule) Name() string { return m.name } +func (m *metricsTestModule) Init(app Application) error { return nil } +func (m *metricsTestModule) CollectMetrics(ctx context.Context) ModuleMetrics { + return ModuleMetrics{ + Name: m.name, + Values: map[string]float64{"requests_total": 100, "error_rate": 0.02}, + } +} + +type nonMetricsModule struct { + name string +} + +func (m *nonMetricsModule) Name() string { return m.name } +func (m *nonMetricsModule) Init(app Application) error { return nil } + +func TestCollectAllMetrics(t *testing.T) { + modA := &metricsTestModule{name: "api"} + modB := &nonMetricsModule{name: "no-metrics"} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + + if len(metrics) != 1 { + t.Fatalf("expected 1 metrics result, got %d", len(metrics)) + } + if metrics[0].Name != "api" { + t.Errorf("expected api, got %s", metrics[0].Name) + } + if metrics[0].Values["requests_total"] != 100 { + t.Errorf("expected requests_total=100, got %v", metrics[0].Values["requests_total"]) + } +} + +func TestCollectAllMetrics_NoProviders(t *testing.T) { + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(&nonMetricsModule{name: "plain"}), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + stdApp := app.(*StdApplication) + metrics := stdApp.CollectAllMetrics(context.Background()) + if len(metrics) != 0 { + t.Errorf("expected 0 metrics, got %d", len(metrics)) + } +} diff --git a/module.go b/module.go index 506f4cd9..eed64041 100644 --- a/module.go +++ b/module.go @@ -16,7 +16,10 @@ // } package modular -import "context" +import ( + "context" + "time" +) // Module represents a registrable component in the application. // All modules must implement this interface to be managed by the application. @@ -248,6 +251,28 @@ type ModuleWithConstructor interface { Constructable } +// Reloadable is an optional interface for modules that support dynamic configuration reloading. +// Modules implementing this interface can have their configuration updated at runtime +// without requiring a full application restart. +// +// The reload process is coordinated by the ReloadOrchestrator, which detects configuration +// changes, computes diffs, and calls Reload on each module that supports it. +type Reloadable interface { + // Reload applies configuration changes to the module. + // The changes slice contains only the changes relevant to this module. + // Implementations should apply changes atomically where possible. + Reload(ctx context.Context, changes []ConfigChange) error + + // CanReload reports whether the module can currently accept a reload. + // Modules may return false if they are in a state where reloading is unsafe + // (e.g., mid-transaction, shutting down). + CanReload() bool + + // ReloadTimeout returns the maximum duration allowed for a reload operation. + // The orchestrator will cancel the reload context if this timeout is exceeded. + ReloadTimeout() time.Duration +} + // ModuleRegistry represents a registry of modules keyed by their names. // This is used internally by the application to manage registered modules // and resolve dependencies between them. diff --git a/module_aware_env_config_test.go b/module_aware_env_config_test.go index 8b748e06..e4096d07 100644 --- a/module_aware_env_config_test.go +++ b/module_aware_env_config_test.go @@ -291,7 +291,7 @@ func TestModuleAwareEnvironmentVariableSearching(t *testing.T) { // mockModuleAwareConfigModule is a mock module for testing module-aware configuration type mockModuleAwareConfigModule struct { name string - config interface{} + config any } func (m *mockModuleAwareConfigModule) Name() string { @@ -314,7 +314,7 @@ func (m *mockModuleAwareConfigModule) Init(app Application) error { } // createTestApplication creates a basic application for testing -func createTestApplication(t *testing.T) *StdApplication { +func createTestApplication(_ *testing.T) *StdApplication { logger := &simpleTestLogger{} app := NewStdApplication(nil, logger) return app.(*StdApplication) diff --git a/modules/README.md b/modules/README.md index cde2dc17..2560e141 100644 --- a/modules/README.md +++ b/modules/README.md @@ -2,24 +2,24 @@ This directory contains all the pre-built modules available in the Modular framework. Each module is designed to be plug-and-play, well-documented, and production-ready. -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) ## 📋 Module Directory | Module | Description | Configuration | Dependencies | Go Docs | |----------------------------|------------------------------------------|---------------|----------------------------------------|---------| -| [auth](./auth) | Authentication and authorization with JWT, sessions, password hashing, and OAuth2/OIDC support | [Yes](./auth/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/auth.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/auth) | -| [cache](./cache) | Multi-backend caching with Redis and in-memory support | [Yes](./cache/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/cache.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/cache) | -| [chimux](./chimux) | Chi router integration with middleware support | [Yes](./chimux/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/chimux) | -| [database](./database) | Database connectivity and SQL operations with multiple driver support | [Yes](./database/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/database.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/database) | -| [eventbus](./eventbus) | Asynchronous event handling and pub/sub messaging | [Yes](./eventbus/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/eventbus) | -| [httpclient](./httpclient) | Configurable HTTP client with connection pooling, timeouts, and verbose logging | [Yes](./httpclient/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpclient) | -| [httpserver](./httpserver) | HTTP/HTTPS server with TLS support, graceful shutdown, and configurable timeouts | [Yes](./httpserver/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpserver) | -| [jsonschema](./jsonschema) | JSON Schema validation services | No | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/jsonschema) | -| [letsencrypt](./letsencrypt) | SSL/TLS certificate automation with Let's Encrypt | [Yes](./letsencrypt/config.go) | Works with httpserver | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/letsencrypt) | -| [logmasker](./logmasker) | Centralized log masking with configurable rules and MaskableValue interface | [Yes](./logmasker/module.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/logmasker) | -| [reverseproxy](./reverseproxy) | Reverse proxy with load balancing, circuit breaker, and health monitoring | [Yes](./reverseproxy/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/reverseproxy) | -| [scheduler](./scheduler) | Job scheduling with cron expressions and worker pools | [Yes](./scheduler/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/scheduler) | +| [auth](./auth) | Authentication and authorization with JWT, sessions, password hashing, and OAuth2/OIDC support | [Yes](./auth/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/auth.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/auth) | +| [cache](./cache) | Multi-backend caching with Redis and in-memory support | [Yes](./cache/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/cache.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/cache) | +| [chimux](./chimux) | Chi router integration with middleware support | [Yes](./chimux/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/chimux) | +| [database](./database) | Database connectivity and SQL operations with multiple driver support | [Yes](./database/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/database.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/database) | +| [eventbus](./eventbus) | Asynchronous event handling and pub/sub messaging | [Yes](./eventbus/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/eventbus) | +| [httpclient](./httpclient) | Configurable HTTP client with connection pooling, timeouts, and verbose logging | [Yes](./httpclient/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpclient) | +| [httpserver](./httpserver) | HTTP/HTTPS server with TLS support, graceful shutdown, and configurable timeouts | [Yes](./httpserver/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpserver) | +| [jsonschema](./jsonschema) | JSON Schema validation services | No | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/jsonschema) | +| [letsencrypt](./letsencrypt) | SSL/TLS certificate automation with Let's Encrypt | [Yes](./letsencrypt/config.go) | Works with httpserver | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/letsencrypt) | +| [logmasker](./logmasker) | Centralized log masking with configurable rules and MaskableValue interface | [Yes](./logmasker/module.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/logmasker) | +| [reverseproxy](./reverseproxy) | Reverse proxy with load balancing, circuit breaker, and health monitoring | [Yes](./reverseproxy/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/reverseproxy) | +| [scheduler](./scheduler) | Job scheduling with cron expressions and worker pools | [Yes](./scheduler/config.go) | - | [![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/scheduler) | ## 🚀 Quick Start diff --git a/modules/auth/README.md b/modules/auth/README.md index 9f2ab5cb..f2e52a20 100644 --- a/modules/auth/README.md +++ b/modules/auth/README.md @@ -1,6 +1,6 @@ # Authentication Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/auth.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/auth) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/auth.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/auth) The Authentication module provides comprehensive authentication capabilities for the Modular framework, including JWT tokens, session management, password hashing, and OAuth2/OIDC integration. @@ -16,7 +16,7 @@ The Authentication module provides comprehensive authentication capabilities for ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/auth +go get github.com/GoCodeAlone/modular/modules/auth ``` ## Configuration @@ -71,8 +71,8 @@ auth: package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/auth" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/auth" ) func main() { diff --git a/modules/auth/bdd_core_test.go b/modules/auth/bdd_core_test.go index 3e085ae0..a96f1550 100644 --- a/modules/auth/bdd_core_test.go +++ b/modules/auth/bdd_core_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/auth/bdd_events_test.go b/modules/auth/bdd_events_test.go index 58411de2..94f86656 100644 --- a/modules/auth/bdd_events_test.go +++ b/modules/auth/bdd_events_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" "github.com/golang-jwt/jwt/v5" diff --git a/modules/auth/go.mod b/modules/auth/go.mod index 1997f0f9..cf1ad24c 100644 --- a/modules/auth/go.mod +++ b/modules/auth/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/auth +module github.com/GoCodeAlone/modular/modules/auth -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/golang-jwt/jwt/v5 v5.2.3 diff --git a/modules/auth/go.sum b/modules/auth/go.sum index 03b8430b..29059fc6 100644 --- a/modules/auth/go.sum +++ b/modules/auth/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/auth/module.go b/modules/auth/module.go index 1ac65b7c..75079f9b 100644 --- a/modules/auth/module.go +++ b/modules/auth/module.go @@ -25,7 +25,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/auth/module_test.go b/modules/auth/module_test.go index 9f4f3f47..bbec659a 100644 --- a/modules/auth/module_test.go +++ b/modules/auth/module_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/auth/service.go b/modules/auth/service.go index a69c70c7..fee6805e 100644 --- a/modules/auth/service.go +++ b/modules/auth/service.go @@ -13,7 +13,7 @@ import ( "time" "unicode" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" diff --git a/modules/cache/README.md b/modules/cache/README.md index 498e87a4..5b5e604e 100644 --- a/modules/cache/README.md +++ b/modules/cache/README.md @@ -1,6 +1,6 @@ # Cache Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/cache.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/cache) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/cache.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/cache) The Cache Module provides caching functionality for Modular applications. It offers different cache backend options including in-memory and Redis (placeholder implementation). @@ -16,8 +16,8 @@ The Cache Module provides caching functionality for Modular applications. It off ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/cache" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/cache" ) // Register the cache module with your Modular application diff --git a/modules/cache/bdd_configuration_test.go b/modules/cache/bdd_configuration_test.go index 82a8b394..d8c827de 100644 --- a/modules/cache/bdd_configuration_test.go +++ b/modules/cache/bdd_configuration_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Configuration-related BDD test steps diff --git a/modules/cache/bdd_core_test.go b/modules/cache/bdd_core_test.go index 35be5254..6fca3596 100644 --- a/modules/cache/bdd_core_test.go +++ b/modules/cache/bdd_core_test.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/cache/bdd_event_errors_test.go b/modules/cache/bdd_event_errors_test.go index 49f4f97e..6e416b3e 100644 --- a/modules/cache/bdd_event_errors_test.go +++ b/modules/cache/bdd_event_errors_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation BDD test steps for error scenarios diff --git a/modules/cache/bdd_event_eviction_test.go b/modules/cache/bdd_event_eviction_test.go index f5d56ee2..87355716 100644 --- a/modules/cache/bdd_event_eviction_test.go +++ b/modules/cache/bdd_event_eviction_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/cache/bdd_event_operations_test.go b/modules/cache/bdd_event_operations_test.go index b558610a..cc830e62 100644 --- a/modules/cache/bdd_event_operations_test.go +++ b/modules/cache/bdd_event_operations_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation BDD test steps for basic operations diff --git a/modules/cache/engine.go b/modules/cache/engine.go index 6cd55f7e..484a494d 100644 --- a/modules/cache/engine.go +++ b/modules/cache/engine.go @@ -97,4 +97,8 @@ type CacheEngine interface { // // The context can be used for operation timeouts. DeleteMulti(ctx context.Context, keys []string) error + + // Stats returns engine-specific metrics as key-value pairs. + // Used by the MetricsProvider interface to collect operational metrics. + Stats(ctx context.Context) map[string]float64 } diff --git a/modules/cache/go.mod b/modules/cache/go.mod index d6cb98fd..cb66b2d8 100644 --- a/modules/cache/go.mod +++ b/modules/cache/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/cache +module github.com/GoCodeAlone/modular/modules/cache -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/alicebob/miniredis/v2 v2.35.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 diff --git a/modules/cache/go.sum b/modules/cache/go.sum index db0d10cf..b3a6dcaa 100644 --- a/modules/cache/go.sum +++ b/modules/cache/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= diff --git a/modules/cache/memory.go b/modules/cache/memory.go index 428174c9..66a65820 100644 --- a/modules/cache/memory.go +++ b/modules/cache/memory.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -201,6 +201,18 @@ func (c *MemoryCache) DeleteMulti(ctx context.Context, keys []string) error { return nil } +// Stats returns memory cache metrics. +func (c *MemoryCache) Stats(_ context.Context) map[string]float64 { + c.mutex.RLock() + count := float64(len(c.items)) + maxItems := float64(c.config.MaxItems) + c.mutex.RUnlock() + return map[string]float64{ + "item_count": count, + "max_items": maxItems, + } +} + // startCleanupTimer starts the cleanup timer for expired items func (c *MemoryCache) startCleanupTimer(ctx context.Context) { // Run cleanup immediately on start diff --git a/modules/cache/module.go b/modules/cache/module.go index 6bd95e46..a37b6c37 100644 --- a/modules/cache/module.go +++ b/modules/cache/module.go @@ -69,7 +69,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -102,6 +102,7 @@ const ServiceName = "cache.provider" type CacheModule struct { name string config *CacheConfig + configMu sync.RWMutex // protects config field reads/writes during reload logger modular.Logger cacheEngine CacheEngine // subject is the observable subject used for event emission. It can be written @@ -377,7 +378,9 @@ func (m *CacheModule) Get(ctx context.Context, key string) (interface{}, bool) { // err := cache.Set(ctx, "session:abc", sessionData, time.Hour) func (m *CacheModule) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { if ttl == 0 { + m.configMu.RLock() ttl = m.config.DefaultTTL + m.configMu.RUnlock() } if err := m.cacheEngine.Set(ctx, key, value, ttl); err != nil { @@ -511,7 +514,9 @@ func (m *CacheModule) GetMulti(ctx context.Context, keys []string) (map[string]i // err := cache.SetMulti(ctx, items, time.Minute*30) func (m *CacheModule) SetMulti(ctx context.Context, items map[string]interface{}, ttl time.Duration) error { if ttl == 0 { + m.configMu.RLock() ttl = m.config.DefaultTTL + m.configMu.RUnlock() } if err := m.cacheEngine.SetMulti(ctx, items, ttl); err != nil { return fmt.Errorf("failed to set multiple cache items: %w", err) diff --git a/modules/cache/module_test.go b/modules/cache/module_test.go index 0fb15120..cfe4303b 100644 --- a/modules/cache/module_test.go +++ b/modules/cache/module_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/cache/redis.go b/modules/cache/redis.go index 8f359574..4b890d2c 100644 --- a/modules/cache/redis.go +++ b/modules/cache/redis.go @@ -190,3 +190,19 @@ func (c *RedisCache) DeleteMulti(ctx context.Context, keys []string) error { } return nil } + +// Stats returns redis cache metrics using pool statistics (no network round-trip). +func (c *RedisCache) Stats(_ context.Context) map[string]float64 { + if c.client == nil { + return map[string]float64{"connected": 0} + } + ps := c.client.PoolStats() + return map[string]float64{ + "connected": 1, + "pool_hits": float64(ps.Hits), + "pool_misses": float64(ps.Misses), + "total_conns": float64(ps.TotalConns), + "idle_conns": float64(ps.IdleConns), + "stale_conns": float64(ps.StaleConns), + } +} diff --git a/modules/cache/v2_interfaces.go b/modules/cache/v2_interfaces.go new file mode 100644 index 00000000..5179c08c --- /dev/null +++ b/modules/cache/v2_interfaces.go @@ -0,0 +1,71 @@ +package cache + +import ( + "context" + "fmt" + "time" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface checks. +var ( + _ modular.MetricsProvider = (*CacheModule)(nil) + _ modular.Reloadable = (*CacheModule)(nil) +) + +// CollectMetrics implements modular.MetricsProvider. +// It delegates to the underlying CacheEngine's Stats method. +// Safe to call before Init (returns empty metrics when engine is nil). +func (m *CacheModule) CollectMetrics(ctx context.Context) modular.ModuleMetrics { + if m.cacheEngine == nil { + return modular.ModuleMetrics{Name: m.name, Values: map[string]float64{}} + } + return modular.ModuleMetrics{ + Name: m.name, + Values: m.cacheEngine.Stats(ctx), + } +} + +// CanReload implements modular.Reloadable. +func (m *CacheModule) CanReload() bool { + return true +} + +// ReloadTimeout implements modular.Reloadable. +func (m *CacheModule) ReloadTimeout() time.Duration { + return 5 * time.Second +} + +// Reload implements modular.Reloadable. +// It applies configuration changes for DefaultTTL and MaxItems. +// CleanupInterval is not reloadable since the cleanup ticker is already running. +// Config writes are protected by configMu (and the engine's mutex for MaxItems) +// to avoid data races with concurrent reads. +func (m *CacheModule) Reload(_ context.Context, changes []modular.ConfigChange) error { + for _, ch := range changes { + switch ch.FieldPath { + case "defaultTTL": + if d, err := time.ParseDuration(ch.NewValue); err == nil { + m.configMu.Lock() + m.config.DefaultTTL = d + m.configMu.Unlock() + } + case "maxItems": + var n int + if _, err := fmt.Sscan(ch.NewValue, &n); err == nil && n > 0 { + m.configMu.Lock() + // MemoryCache reads MaxItems under its own mutex, so lock both. + if mc, ok := m.cacheEngine.(*MemoryCache); ok { + mc.mutex.Lock() + m.config.MaxItems = n + mc.mutex.Unlock() + } else { + m.config.MaxItems = n + } + m.configMu.Unlock() + } + } + } + return nil +} diff --git a/modules/cache/v2_interfaces_test.go b/modules/cache/v2_interfaces_test.go new file mode 100644 index 00000000..d0ab9f58 --- /dev/null +++ b/modules/cache/v2_interfaces_test.go @@ -0,0 +1,73 @@ +package cache + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestCacheModule creates a CacheModule initialised with a memory engine for testing. +func newTestCacheModule(t *testing.T) (*CacheModule, context.Context) { + t.Helper() + module := NewModule().(*CacheModule) + app := newMockApp() + + // Pre-register config with explicit values so struct-tag defaults are not needed. + cfg := &CacheConfig{ + Engine: "memory", + DefaultTTL: 300 * time.Second, + CleanupInterval: 60 * time.Second, + MaxItems: 10000, + } + app.RegisterConfigSection(module.Name(), modular.NewStdConfigProvider(cfg)) + + require.NoError(t, module.RegisterConfig(app)) // skips (already registered) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + t.Cleanup(func() { _ = module.Stop(ctx) }) + + return module, ctx +} + +func TestCacheModule_CollectMetrics(t *testing.T) { + t.Parallel() + + module, ctx := newTestCacheModule(t) + + // Add some items + require.NoError(t, module.Set(ctx, "key1", "val1", time.Minute)) + require.NoError(t, module.Set(ctx, "key2", "val2", time.Minute)) + require.NoError(t, module.Set(ctx, "key3", "val3", time.Minute)) + + metrics := module.CollectMetrics(ctx) + assert.Equal(t, "cache", metrics.Name) + assert.Equal(t, 3.0, metrics.Values["item_count"]) + assert.Equal(t, 10000.0, metrics.Values["max_items"]) +} + +func TestCacheModule_Reloadable(t *testing.T) { + t.Parallel() + + module, ctx := newTestCacheModule(t) + + // Verify interface compliance + var reloadable modular.Reloadable = module + assert.True(t, reloadable.CanReload()) + assert.Equal(t, 5*time.Second, reloadable.ReloadTimeout()) + + // Verify reload updates config (cleanupInterval is not reloadable) + changes := []modular.ConfigChange{ + {FieldPath: "defaultTTL", NewValue: "600s"}, + {FieldPath: "maxItems", NewValue: "5000"}, + } + require.NoError(t, reloadable.Reload(ctx, changes)) + + assert.Equal(t, 600*time.Second, module.config.DefaultTTL) + assert.Equal(t, 5000, module.config.MaxItems) +} diff --git a/modules/chimux/README.md b/modules/chimux/README.md index c650d59b..fb6c87d6 100644 --- a/modules/chimux/README.md +++ b/modules/chimux/README.md @@ -1,8 +1,8 @@ # chimux Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/chimux) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/chimux.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/chimux) -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework. ## Overview @@ -22,7 +22,7 @@ The chimux module provides a powerful HTTP router and middleware system for Modu ## Installation ```go -go get github.com/CrisisTextLine/modular/modules/chimux@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/chimux@v1.0.0 ``` ## Usage @@ -31,8 +31,8 @@ go get github.com/CrisisTextLine/modular/modules/chimux@v1.0.0 package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" "log/slog" "net/http" "os" diff --git a/modules/chimux/bdd_config_test.go b/modules/chimux/bdd_config_test.go index 09b657c8..0d51354e 100644 --- a/modules/chimux/bdd_config_test.go +++ b/modules/chimux/bdd_config_test.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static errors for bdd_config_test.go diff --git a/modules/chimux/bdd_core_test.go b/modules/chimux/bdd_core_test.go index 239e3d14..1562993e 100644 --- a/modules/chimux/bdd_core_test.go +++ b/modules/chimux/bdd_core_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/chimux/bdd_cors_test.go b/modules/chimux/bdd_cors_test.go index 40205674..fc40b244 100644 --- a/modules/chimux/bdd_cors_test.go +++ b/modules/chimux/bdd_cors_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static errors for bdd_cors_test.go diff --git a/modules/chimux/bdd_events_test.go b/modules/chimux/bdd_events_test.go index d8889dd8..5905817b 100644 --- a/modules/chimux/bdd_events_test.go +++ b/modules/chimux/bdd_events_test.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Static errors for bdd_events_test.go diff --git a/modules/chimux/chimux_race_test.go b/modules/chimux/chimux_race_test.go index 50434e33..21713f7a 100644 --- a/modules/chimux/chimux_race_test.go +++ b/modules/chimux/chimux_race_test.go @@ -3,8 +3,8 @@ package chimux_test import ( "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/chimux/go.mod b/modules/chimux/go.mod index 4079fdb8..78f1d7f4 100644 --- a/modules/chimux/go.mod +++ b/modules/chimux/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/chimux +module github.com/GoCodeAlone/modular/modules/chimux -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-chi/chi/v5 v5.2.2 diff --git a/modules/chimux/go.sum b/modules/chimux/go.sum index 6e04ae39..99496613 100644 --- a/modules/chimux/go.sum +++ b/modules/chimux/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/chimux/mock_test.go b/modules/chimux/mock_test.go index 16958948..ad449f27 100644 --- a/modules/chimux/mock_test.go +++ b/modules/chimux/mock_test.go @@ -7,7 +7,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/chimux/module.go b/modules/chimux/module.go index 9875bf2b..61616947 100644 --- a/modules/chimux/module.go +++ b/modules/chimux/module.go @@ -94,7 +94,7 @@ import ( "sync/atomic" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" diff --git a/modules/chimux/module_test.go b/modules/chimux/module_test.go index 0e0b227d..99bb813d 100644 --- a/modules/chimux/module_test.go +++ b/modules/chimux/module_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/configwatcher/configwatcher.go b/modules/configwatcher/configwatcher.go new file mode 100644 index 00000000..1c597c84 --- /dev/null +++ b/modules/configwatcher/configwatcher.go @@ -0,0 +1,164 @@ +package configwatcher + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/fsnotify/fsnotify" +) + +// ConfigWatcher watches config files and calls OnChange when modifications are detected. +type ConfigWatcher struct { + paths []string + debounce time.Duration + onChange func(paths []string) + watcher *fsnotify.Watcher + stopCh chan struct{} + stopOnce sync.Once + logger modular.Logger +} + +// Option configures a ConfigWatcher. +type Option func(*ConfigWatcher) + +func WithPaths(paths ...string) Option { + return func(w *ConfigWatcher) { w.paths = append(w.paths, paths...) } +} + +func WithDebounce(d time.Duration) Option { + return func(w *ConfigWatcher) { w.debounce = d } +} + +func WithOnChange(fn func(paths []string)) Option { + return func(w *ConfigWatcher) { w.onChange = fn } +} + +func WithLogger(l modular.Logger) Option { + return func(w *ConfigWatcher) { w.logger = l } +} + +func New(opts ...Option) *ConfigWatcher { + w := &ConfigWatcher{ + debounce: 500 * time.Millisecond, + stopCh: make(chan struct{}), + } + for _, opt := range opts { + opt(w) + } + return w +} + +func (w *ConfigWatcher) Name() string { return "configwatcher" } + +// Init satisfies the modular.Module interface. Captures the application logger +// if one was not provided via WithLogger. +func (w *ConfigWatcher) Init(app modular.Application) error { + if w.logger == nil { + w.logger = app.Logger() + } + return nil +} + +func (w *ConfigWatcher) Start(ctx context.Context) error { + if err := w.startWatching(); err != nil { + return err + } + go func() { + select { + case <-ctx.Done(): + _ = w.stopWatching() + case <-w.stopCh: + } + }() + return nil +} + +func (w *ConfigWatcher) Stop(_ context.Context) error { + return w.stopWatching() +} + +func (w *ConfigWatcher) startWatching() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("creating file watcher: %w", err) + } + w.watcher = watcher + for _, path := range w.paths { + if err := watcher.Add(path); err != nil { + watcher.Close() + return fmt.Errorf("watching path %q: %w", path, err) + } + } + go w.eventLoop() + return nil +} + +func (w *ConfigWatcher) stopWatching() error { + var closeErr error + w.stopOnce.Do(func() { + close(w.stopCh) + if w.watcher != nil { + if err := w.watcher.Close(); err != nil { + closeErr = fmt.Errorf("closing file watcher: %w", err) + } + } + }) + return closeErr +} + +func (w *ConfigWatcher) eventLoop() { + var timer *time.Timer + changedPaths := make(map[string]struct{}) + var mu sync.Mutex + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + mu.Lock() + changedPaths[event.Name] = struct{}{} + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(w.debounce, func() { + // Check stopCh before invoking callback to avoid + // firing onChange after shutdown. + select { + case <-w.stopCh: + return + default: + } + if w.onChange != nil { + mu.Lock() + paths := make([]string, 0, len(changedPaths)) + for p := range changedPaths { + paths = append(paths, p) + } + clear(changedPaths) + mu.Unlock() + w.onChange(paths) + } + }) + mu.Unlock() + } + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + if w.logger != nil { + w.logger.Error("file watcher error", "error", err) + } + case <-w.stopCh: + if timer != nil { + timer.Stop() + } + return + } + } +} diff --git a/modules/configwatcher/configwatcher_test.go b/modules/configwatcher/configwatcher_test.go new file mode 100644 index 00000000..d27d01e7 --- /dev/null +++ b/modules/configwatcher/configwatcher_test.go @@ -0,0 +1,77 @@ +package configwatcher + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestConfigWatcher_DetectsFileChange(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("key: value1"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(50*time.Millisecond), + WithOnChange(func(paths []string) { changeCount.Add(1) }), + ) + + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + if err := os.WriteFile(cfgFile, []byte("key: value2"), 0644); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if changeCount.Load() > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if changeCount.Load() == 0 { + t.Error("expected at least one change notification") + } +} + +func TestConfigWatcher_Debounces(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgFile, []byte("v1"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + var changeCount atomic.Int32 + w := New( + WithPaths(cfgFile), + WithDebounce(200*time.Millisecond), + WithOnChange(func(paths []string) { changeCount.Add(1) }), + ) + if err := w.startWatching(); err != nil { + t.Fatalf("startWatching: %v", err) + } + defer w.stopWatching() + + time.Sleep(100 * time.Millisecond) + for i := range 5 { + if err := os.WriteFile(cfgFile, []byte("v"+string(rune('2'+i))), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + time.Sleep(20 * time.Millisecond) + } + time.Sleep(500 * time.Millisecond) + + if changeCount.Load() > 2 { + t.Errorf("expected debounced to ~1-2 calls, got %d", changeCount.Load()) + } +} diff --git a/modules/configwatcher/go.mod b/modules/configwatcher/go.mod new file mode 100644 index 00000000..4e168433 --- /dev/null +++ b/modules/configwatcher/go.mod @@ -0,0 +1,22 @@ +module github.com/GoCodeAlone/modular/modules/configwatcher + +go 1.26 + +require ( + github.com/GoCodeAlone/modular v1.12.2 + github.com/fsnotify/fsnotify v1.9.0 +) + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect + github.com/golobby/cast v1.3.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/sys v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/modules/configwatcher/go.sum b/modules/configwatcher/go.sum new file mode 100644 index 00000000..f2d51bd9 --- /dev/null +++ b/modules/configwatcher/go.sum @@ -0,0 +1,86 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= +github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= +github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golobby/cast v1.3.3 h1:s2Lawb9RMz7YyYf8IrfMQY4IFmA1R/lgfmj97Vc6fig= +github.com/golobby/cast v1.3.3/go.mod h1:0oDO5IT84HTXcbLDf1YXuk0xtg/cRDrxhbpWKxwtJCY= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +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/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +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= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.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= diff --git a/modules/database/AWS_IAM_AUTH.md b/modules/database/AWS_IAM_AUTH.md index dbab11df..eb1a1c88 100644 --- a/modules/database/AWS_IAM_AUTH.md +++ b/modules/database/AWS_IAM_AUTH.md @@ -20,9 +20,9 @@ When AWS IAM authentication is enabled, **any password in the DSN is ignored and ```yaml # All of these DSN formats work identically with IAM auth: -dsn: "postgresql://chimera_app:$TOKEN@host.rds.amazonaws.com:5432/mydb" -dsn: "postgresql://chimera_app:PLACEHOLDER@host.rds.amazonaws.com:5432/mydb" -dsn: "postgresql://chimera_app@host.rds.amazonaws.com:5432/mydb" +dsn: "postgresql://myapp_user:$TOKEN@host.rds.amazonaws.com:5432/mydb" +dsn: "postgresql://myapp_user:PLACEHOLDER@host.rds.amazonaws.com:5432/mydb" +dsn: "postgresql://myapp_user@host.rds.amazonaws.com:5432/mydb" ``` The password portion (`$TOKEN`, `PLACEHOLDER`, or empty) is completely ignored when IAM auth is enabled. @@ -33,7 +33,7 @@ The database username is extracted from the DSN or can be explicitly specified: ```yaml # Option 1: Username in DSN (extracted automatically) -dsn: "postgresql://chimera_app:$TOKEN@host.rds.amazonaws.com:5432/mydb" +dsn: "postgresql://myapp_user:$TOKEN@host.rds.amazonaws.com:5432/mydb" aws_iam_auth: enabled: true region: us-east-1 @@ -43,7 +43,7 @@ dsn: "postgresql://ignored_user:$TOKEN@host.rds.amazonaws.com:5432/mydb" aws_iam_auth: enabled: true region: us-east-1 - db_user: chimera_app # This takes precedence + db_user: myapp_user # This takes precedence ``` ### 3. Token Generation Flow @@ -66,7 +66,7 @@ database: writer: driver: postgres # DSN with $TOKEN placeholder - will be automatically stripped - dsn: "postgresql://chimera_app:$TOKEN@shared-chimera-dev-backend.cluster-xyz.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require" + dsn: "postgresql://myapp_user:$TOKEN@mydb-instance.cluster-xyz.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require" max_open_connections: 25 max_idle_connections: 10 connection_max_lifetime: 1h @@ -82,7 +82,7 @@ database: ```bash export DB_WRITER_DRIVER=postgres -export DB_WRITER_DSN="postgresql://chimera_app:$TOKEN@host.rds.amazonaws.com:5432/mydb?sslmode=require" +export DB_WRITER_DSN="postgresql://myapp_user:$TOKEN@host.rds.amazonaws.com:5432/mydb?sslmode=require" export DB_WRITER_AWS_IAM_AUTH_ENABLED=true export DB_WRITER_AWS_IAM_AUTH_REGION=us-east-1 export DB_WRITER_MAX_OPEN_CONNECTIONS=25 @@ -102,15 +102,15 @@ Create a database user configured for IAM authentication: **PostgreSQL:** ```sql -CREATE USER chimera_app WITH LOGIN; -GRANT rds_iam TO chimera_app; -GRANT ALL PRIVILEGES ON DATABASE chimera_backend TO chimera_app; +CREATE USER myapp_user WITH LOGIN; +GRANT rds_iam TO myapp_user; +GRANT ALL PRIVILEGES ON DATABASE myappdb TO myapp_user; ``` **MySQL:** ```sql -CREATE USER chimera_app IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'; -GRANT ALL PRIVILEGES ON chimera_backend.* TO chimera_app@'%'; +CREATE USER myapp_user IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'; +GRANT ALL PRIVILEGES ON myappdb.* TO myapp_user@'%'; ``` ### 3. IAM Policy @@ -125,7 +125,7 @@ The AWS principal (user/role) must have `rds-db:connect` permission: "Effect": "Allow", "Action": ["rds-db:connect"], "Resource": [ - "arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/chimera_app" + "arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/myapp_user" ] } ] @@ -135,7 +135,7 @@ The AWS principal (user/role) must have `rds-db:connect` permission: **Finding your Resource ARN:** - Format: `arn:aws:rds-db:REGION:ACCOUNT:dbuser:RESOURCE_ID/DB_USERNAME` - Get RESOURCE_ID from RDS console (cluster identifier starts with `cluster-`) -- Example: `arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-ABC123DEF456/chimera_app` +- Example: `arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-ABC123DEF456/myapp_user` ### 4. AWS Credentials @@ -155,7 +155,7 @@ database: connections: writer: driver: postgres - dsn: "postgresql://myuser:MySecretPassword123@host.rds.amazonaws.com:5432/mydb" + dsn: "postgresql://myuser:MySecretP@ssword@host.rds.amazonaws.com:5432/mydb" ``` ### After (with IAM): @@ -173,18 +173,18 @@ database: **The password portion is completely ignored when IAM auth is enabled.** -## Your Specific Use Case +## Example Use Case -You mentioned passing the DSN as: +Here is a complete example DSN for an RDS Aurora PostgreSQL cluster: ``` -postgresql://chimera_app:$TOKEN@shared-chimera-dev-backend.cluster-cbysgk6e0u2x.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require +postgresql://myapp_user:$TOKEN@mydb-instance.cluster-abc123def456.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require ``` -**This is exactly the correct format!** Here's what happens: +**This is the correct format.** Here's what happens: 1. ✅ The module sees `aws_iam_auth.enabled: true` 2. ✅ The `$TOKEN` placeholder is automatically stripped from the DSN -3. ✅ The username `chimera_app` is extracted and used for IAM authentication +3. ✅ The username `myapp_user` is extracted and used for IAM authentication 4. ✅ AWS credentials are loaded from your environment 5. ✅ An RDS IAM token is generated automatically 6. ✅ The token is refreshed every ~15 minutes automatically @@ -237,10 +237,10 @@ DEBUG Processing DSN for IAM authentication original_dsn_length=142 DEBUG Password stripped from DSN cleaned_dsn_length=128 INFO Extracted RDS endpoint endpoint=mydb.cluster-xyz.us-east-1.rds.amazonaws.com:5432 DEBUG Extracted database configuration database=mydb options_count=1 -DEBUG Extracted username from DSN username=chimera_app -INFO IAM authentication will use database user username=chimera_app +DEBUG Extracted username from DSN username=myapp_user +INFO IAM authentication will use database user username=myapp_user DEBUG Determined database driver configuration driver=pgx port=5432 -INFO Creating AWS RDS credential store endpoint=mydb... region=us-east-1 username=chimera_app +INFO Creating AWS RDS credential store endpoint=mydb... region=us-east-1 username=myapp_user DEBUG AWS RDS credential store created successfully INFO Database connection with AWS IAM authentication configured successfully DEBUG Testing database connection timeout=10s @@ -353,17 +353,17 @@ You can test IAM authentication manually: ```bash # Generate a token TOKEN=$(aws rds generate-db-auth-token \ - --hostname shared-chimera-dev-backend.cluster-xyz.us-east-1.rds.amazonaws.com \ + --hostname mydb-instance.cluster-xyz.us-east-1.rds.amazonaws.com \ --port 5432 \ - --username chimera_app \ + --username myapp_user \ --region us-east-1) # Connect using the token PGPASSWORD=$TOKEN psql \ - -h shared-chimera-dev-backend.cluster-xyz.us-east-1.rds.amazonaws.com \ + -h mydb-instance.cluster-xyz.us-east-1.rds.amazonaws.com \ -p 5432 \ - -U chimera_app \ - -d chimera_backend + -U myapp_user \ + -d myappdb ``` ## Benefits of IAM Authentication diff --git a/modules/database/DIAGNOSTICS.md b/modules/database/DIAGNOSTICS.md index 108cf127..413e8f71 100644 --- a/modules/database/DIAGNOSTICS.md +++ b/modules/database/DIAGNOSTICS.md @@ -114,13 +114,13 @@ ERROR Failed to extract RDS endpoint from DSN **Valid DSN Formats:** ```yaml # URL style with placeholder -dsn: "postgresql://chimera_app:$TOKEN@mydb.us-east-1.rds.amazonaws.com:5432/chimera_backend" +dsn: "postgresql://myapp_user:$TOKEN@mydb.us-east-1.rds.amazonaws.com:5432/myappdb" # URL style without password -dsn: "postgresql://chimera_app@mydb.us-east-1.rds.amazonaws.com:5432/chimera_backend" +dsn: "postgresql://myapp_user@mydb.us-east-1.rds.amazonaws.com:5432/myappdb" # With options -dsn: "postgresql://chimera_app@mydb.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require" +dsn: "postgresql://myapp_user@mydb.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require" ``` **Common Mistakes:** @@ -147,7 +147,7 @@ ERROR Database username not found **Option 1:** Include username in DSN ```yaml # Include username after :// -dsn: "postgresql://chimera_app@mydb.rds.amazonaws.com:5432/mydb" +dsn: "postgresql://myapp_user@mydb.rds.amazonaws.com:5432/mydb" ``` **Option 2:** Specify in config @@ -155,7 +155,7 @@ dsn: "postgresql://chimera_app@mydb.rds.amazonaws.com:5432/mydb" aws_iam_auth: enabled: true region: us-east-1 - db_user: chimera_app # ← Specify username here + db_user: myapp_user # ← Specify username here ``` --- @@ -183,7 +183,7 @@ ERROR Failed to create AWS RDS credential store aws rds generate-db-auth-token \ --hostname mydb.us-east-1.rds.amazonaws.com \ --port 5432 \ - --username chimera_app \ + --username myapp_user \ --region us-east-1 ``` Should output a token string. @@ -209,7 +209,7 @@ ERROR Failed to create AWS RDS credential store **Error Message:** ``` ERROR Database ping failed with IAM authentication - error="pq: password authentication failed for user \"chimera_app\"" + error="pq: password authentication failed for user \"myapp_user\"" timeout=10s possible_causes=["IAM token generation failed", "Database user doesn't have rds_iam role", ...] ``` @@ -223,19 +223,19 @@ This is the most common production error. Follow this systematic diagnostic: -- Connect as master user SELECT rolname, rolcanlogin FROM pg_roles -WHERE rolname = 'chimera_app'; +WHERE rolname = 'myapp_user'; -- Check if user has rds_iam role SELECT r.rolname FROM pg_roles r JOIN pg_auth_members m ON r.oid = m.member WHERE m.roleid = (SELECT oid FROM pg_roles WHERE rolname = 'rds_iam') - AND r.rolname = 'chimera_app'; + AND r.rolname = 'myapp_user'; ``` If the user doesn't have `rds_iam` role: ```sql -GRANT rds_iam TO chimera_app; +GRANT rds_iam TO myapp_user; ``` **MySQL:** @@ -243,15 +243,15 @@ GRANT rds_iam TO chimera_app; -- Check if user exists and uses IAM plugin SELECT user, host, plugin FROM mysql.user -WHERE user = 'chimera_app'; +WHERE user = 'myapp_user'; ``` User should have `plugin = 'AWSAuthenticationPlugin'`. If not: ```sql -CREATE USER chimera_app IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'; -GRANT ALL PRIVILEGES ON chimera_backend.* TO chimera_app@'%'; +CREATE USER myapp_user IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'; +GRANT ALL PRIVILEGES ON myappdb.* TO myapp_user@'%'; ``` #### Step 2: Verify IAM Policy @@ -266,7 +266,7 @@ Check your IAM policy allows `rds-db:connect`: "Effect": "Allow", "Action": ["rds-db:connect"], "Resource": [ - "arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/chimera_app" + "arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/myapp_user" ] } ] @@ -290,7 +290,7 @@ aws rds describe-db-clusters \ aws iam simulate-principal-policy \ --policy-source-arn arn:aws:iam::123456789012:role/my-role \ --action-names rds-db:connect \ - --resource-arns "arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/chimera_app" + --resource-arns "arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/myapp_user" ``` #### Step 3: Test Manual Connection @@ -301,17 +301,17 @@ Generate a token and try connecting manually: ```bash # Generate token TOKEN=$(aws rds generate-db-auth-token \ - --hostname shared-chimera-dev-backend.cluster-xyz.us-east-1.rds.amazonaws.com \ + --hostname mydb-instance.cluster-xyz.us-east-1.rds.amazonaws.com \ --port 5432 \ - --username chimera_app \ + --username myapp_user \ --region us-east-1) # Try connecting PGPASSWORD=$TOKEN psql \ - -h shared-chimera-dev-backend.cluster-xyz.us-east-1.rds.amazonaws.com \ + -h mydb-instance.cluster-xyz.us-east-1.rds.amazonaws.com \ -p 5432 \ - -U chimera_app \ - -d chimera_backend + -U myapp_user \ + -d myappdb ``` If manual connection works but application doesn't: @@ -429,7 +429,7 @@ aws rds generate-db-auth-token \ --region REGION # Database User Check (PostgreSQL) -psql -h HOST -U master_user -d postgres -c "\du+ chimera_app" +psql -h HOST -U master_user -d postgres -c "\du+ myapp_user" # RDS IAM Status aws rds describe-db-instances \ diff --git a/modules/database/IAM_TOKEN_ROTATION_FIX.md b/modules/database/IAM_TOKEN_ROTATION_FIX.md index 37c6c6d3..2261780a 100644 --- a/modules/database/IAM_TOKEN_ROTATION_FIX.md +++ b/modules/database/IAM_TOKEN_ROTATION_FIX.md @@ -159,7 +159,7 @@ $ go test -v -run "TestTTLStore" --- PASS: TestTTLStore_RealWorldScenario (3.01s) PASS -ok github.com/CrisisTextLine/modular/modules/database/v2 3.631s +ok github.com/GoCodeAlone/modular/modules/database/v2 3.631s ``` ### What The Tests Prove diff --git a/modules/database/README.md b/modules/database/README.md index 408086d0..4951bea7 100644 --- a/modules/database/README.md +++ b/modules/database/README.md @@ -1,9 +1,9 @@ # Database Module for Modular -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/database.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/database) -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/database.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/database) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) -A [Modular](https://github.com/CrisisTextLine/modular) module that provides database connectivity and management. +A [Modular](https://github.com/GoCodeAlone/modular) module that provides database connectivity and management. ## Overview @@ -20,7 +20,7 @@ The Database module provides a service for connecting to and interacting with SQ ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/database +go get github.com/GoCodeAlone/modular/modules/database ``` ## Usage @@ -31,8 +31,8 @@ The database module uses the standard Go `database/sql` package, which requires ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/database" // Import database drivers as needed _ "github.com/lib/pq" // PostgreSQL driver @@ -58,8 +58,8 @@ go get github.com/mattn/go-sqlite3 ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/database" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/database" _ "github.com/lib/pq" // Import PostgreSQL driver ) diff --git a/modules/database/bdd_basic_operations_test.go b/modules/database/bdd_basic_operations_test.go index ba9d2256..96310a53 100644 --- a/modules/database/bdd_basic_operations_test.go +++ b/modules/database/bdd_basic_operations_test.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Module initialization and basic database operations diff --git a/modules/database/bdd_core_test.go b/modules/database/bdd_core_test.go index 6cb0c132..bdad01a8 100644 --- a/modules/database/bdd_core_test.go +++ b/modules/database/bdd_core_test.go @@ -6,7 +6,7 @@ import ( "sync" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" _ "modernc.org/sqlite" // Import pure-Go SQLite driver for BDD tests (works with CGO_DISABLED) diff --git a/modules/database/bdd_events_test.go b/modules/database/bdd_events_test.go index f61599fe..b9d41318 100644 --- a/modules/database/bdd_events_test.go +++ b/modules/database/bdd_events_test.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation and emission functionality diff --git a/modules/database/config.go b/modules/database/config.go index d2817fed..37f4de9d 100644 --- a/modules/database/config.go +++ b/modules/database/config.go @@ -67,11 +67,11 @@ type ConnectionConfig struct { // You can include a placeholder like $TOKEN in the DSN for clarity, but it will be removed: // // Example DSN: -// postgresql://chimera_app:$TOKEN@mydb.us-east-1.rds.amazonaws.com:5432/mydb?sslmode=require +// postgresql://myapp_user:$TOKEN@mydb.us-east-1.rds.amazonaws.com:5432/mydb?sslmode=require // // The module will: // 1. Strip the "$TOKEN" placeholder from the DSN -// 2. Extract the username "chimera_app" +// 2. Extract the username "myapp_user" // 3. Use AWS credentials to generate an IAM auth token // 4. Automatically refresh the token before it expires // diff --git a/modules/database/config_env_test.go b/modules/database/config_env_test.go index 2ec45b1a..dfc4b01c 100644 --- a/modules/database/config_env_test.go +++ b/modules/database/config_env_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestConnectionConfigEnvMapping tests environment variable mapping for database connections diff --git a/modules/database/config_test.go b/modules/database/config_test.go index c15e22c0..1b55c1e4 100644 --- a/modules/database/config_test.go +++ b/modules/database/config_test.go @@ -3,7 +3,7 @@ package database import ( "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestGetInstanceConfigs_ReturnsOriginalPointers tests that GetInstanceConfigs returns diff --git a/modules/database/credential_refresh_store.go b/modules/database/credential_refresh_store.go index 60c02c90..0e26d877 100644 --- a/modules/database/credential_refresh_store.go +++ b/modules/database/credential_refresh_store.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/aws/aws-sdk-go-v2/config" "github.com/davepgreene/go-db-credential-refresh/driver" "github.com/davepgreene/go-db-credential-refresh/store/awsrds" @@ -25,7 +25,7 @@ var ( // // AWS IAM Authentication Flow: // 1. Any password/token in the DSN is stripped (including placeholders like $TOKEN) -// 2. Username is extracted from the DSN (e.g., "chimera_app" from postgresql://chimera_app:$TOKEN@host/db) +// 2. Username is extracted from the DSN (e.g., "myapp_user" from postgresql://myapp_user:$TOKEN@host/db) // 3. AWS credentials are loaded from the environment/instance profile/etc. // 4. RDS IAM auth token is automatically generated using AWS credentials // 5. Token is automatically refreshed before expiration @@ -33,11 +33,11 @@ var ( // // This means you can pass a DSN with a placeholder token like: // -// postgresql://chimera_app:$TOKEN@shared-chimera-dev-backend.cluster-xyz.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require +// postgresql://myapp_user:$TOKEN@mydb-instance.cluster-xyz.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require // // And the module will: // - Ignore the "$TOKEN" placeholder -// - Use "chimera_app" as the database username for IAM authentication +// - Use "myapp_user" as the database username for IAM authentication // - Automatically generate and refresh the actual IAM token // // Configuration Requirements: @@ -232,12 +232,12 @@ func configureConnectionPool(db *sql.DB, config ConnectionConfig) { // - The actual IAM token is obtained using AWS credentials and the username extracted from the DSN // // Example DSNs that will have their passwords stripped: -// - postgresql://chimera_app:$TOKEN@host.rds.amazonaws.com:5432/mydb?sslmode=require -// becomes: postgresql://chimera_app@host.rds.amazonaws.com:5432/mydb?sslmode=require +// - postgresql://myapp_user:$TOKEN@host.rds.amazonaws.com:5432/mydb?sslmode=require +// becomes: postgresql://myapp_user@host.rds.amazonaws.com:5432/mydb?sslmode=require // - postgres://user:some_placeholder@host:5432/mydb // becomes: postgres://user@host:5432/mydb // -// The username (e.g., "chimera_app") is preserved and used for IAM authentication. +// The username (e.g., "myapp_user") is preserved and used for IAM authentication. func stripPasswordFromDSN(dsn string) string { if strings.Contains(dsn, "://") { // URL-style DSN (e.g., postgres://user:password@host:port/database) diff --git a/modules/database/credential_refresh_test.go b/modules/database/credential_refresh_test.go index e3e2fd1e..2163b695 100644 --- a/modules/database/credential_refresh_test.go +++ b/modules/database/credential_refresh_test.go @@ -307,8 +307,8 @@ func TestStripPasswordFromDSN(t *testing.T) { }, { name: "URL-style DSN with $TOKEN placeholder", - dsn: "postgresql://chimera_app:$TOKEN@shared-chimera-dev-backend.cluster-cbysgk6e0u2x.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require", - expected: "postgresql://chimera_app@shared-chimera-dev-backend.cluster-cbysgk6e0u2x.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require", + dsn: "postgresql://myapp_user:$TOKEN@mydb-instance.cluster-abc123def456.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require", + expected: "postgresql://myapp_user@mydb-instance.cluster-abc123def456.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require", }, { name: "URL-style DSN without password", @@ -350,8 +350,8 @@ func TestUsernameExtraction_WithTokenPlaceholder(t *testing.T) { }{ { name: "DSN with $TOKEN placeholder", - dsn: "postgresql://chimera_app:$TOKEN@shared-chimera-dev-backend.cluster-cbysgk6e0u2x.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require", - expected: "chimera_app", + dsn: "postgresql://myapp_user:$TOKEN@mydb-instance.cluster-abc123def456.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require", + expected: "myapp_user", }, { name: "DSN with TOKEN placeholder (no $)", diff --git a/modules/database/db_test.go b/modules/database/db_test.go index 935e4a20..1155b513 100644 --- a/modules/database/db_test.go +++ b/modules/database/db_test.go @@ -10,9 +10,9 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" - "github.com/CrisisTextLine/modular/modules/database/v2" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" + "github.com/GoCodeAlone/modular/modules/database/v2" _ "modernc.org/sqlite" // Import pure Go SQLite driver ) diff --git a/modules/database/examples/aws-iam-auth-config.yaml b/modules/database/examples/aws-iam-auth-config.yaml index 3a6bfdf5..95da5b8f 100644 --- a/modules/database/examples/aws-iam-auth-config.yaml +++ b/modules/database/examples/aws-iam-auth-config.yaml @@ -12,15 +12,15 @@ # Prerequisites: # 1. RDS instance must have IAM authentication enabled # 2. Database user must be created with IAM authentication: -# CREATE USER chimera_app WITH LOGIN; -# GRANT rds_iam TO chimera_app; +# CREATE USER myapp_user WITH LOGIN; +# GRANT rds_iam TO myapp_user; # 3. IAM policy must allow rds-db:connect action: # { # "Version": "2012-10-17", # "Statement": [{ # "Effect": "Allow", # "Action": ["rds-db:connect"], -# "Resource": ["arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/chimera_app"] +# "Resource": ["arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-XXXXX/myapp_user"] # }] # } # 4. AWS credentials must be available (environment variables, instance profile, etc.) @@ -33,7 +33,7 @@ database: driver: postgres # DSN with $TOKEN placeholder - the placeholder will be automatically stripped # and replaced with a dynamically generated IAM token - dsn: "postgresql://chimera_app:$TOKEN@shared-chimera-dev-backend.cluster-cbysgk6e0u2x.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require" + dsn: "postgresql://myapp_user:$TOKEN@mydb-instance.cluster-abc123def456.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require" max_open_connections: 25 max_idle_connections: 10 connection_max_lifetime: 1h @@ -42,14 +42,14 @@ database: enabled: true region: us-east-1 # db_user is optional - if not specified, username is extracted from DSN - # db_user: chimera_app + # db_user: myapp_user connection_timeout: 10s # Reader connection with IAM authentication reader: driver: postgres # Alternative format: leave password empty (no placeholder needed) - dsn: "postgresql://chimera_app@shared-chimera-dev-backend-ro.cluster-cbysgk6e0u2x.us-east-1.rds.amazonaws.com:5432/chimera_backend?sslmode=require" + dsn: "postgresql://myapp_user@mydb-instance-ro.cluster-abc123def456.us-east-1.rds.amazonaws.com:5432/myappdb?sslmode=require" max_open_connections: 50 max_idle_connections: 20 connection_max_lifetime: 1h @@ -57,22 +57,22 @@ database: enabled: true region: us-east-1 # Explicitly specify the database user (takes precedence over DSN username) - db_user: chimera_app + db_user: myapp_user # Environment Variable Configuration Alternative: # You can also configure via environment variables: # # export DB_WRITER_DRIVER=postgres -# export DB_WRITER_DSN="postgresql://chimera_app:$TOKEN@host.rds.amazonaws.com:5432/mydb?sslmode=require" +# export DB_WRITER_DSN="postgresql://myapp_user:$TOKEN@host.rds.amazonaws.com:5432/mydb?sslmode=require" # export DB_WRITER_AWS_IAM_AUTH_ENABLED=true # export DB_WRITER_AWS_IAM_AUTH_REGION=us-east-1 # export DB_WRITER_MAX_OPEN_CONNECTIONS=25 # # export DB_READER_DRIVER=postgres -# export DB_READER_DSN="postgresql://chimera_app@host-ro.rds.amazonaws.com:5432/mydb?sslmode=require" +# export DB_READER_DSN="postgresql://myapp_user@host-ro.rds.amazonaws.com:5432/mydb?sslmode=require" # export DB_READER_AWS_IAM_AUTH_ENABLED=true # export DB_READER_AWS_IAM_AUTH_REGION=us-east-1 -# export DB_READER_AWS_IAM_AUTH_DB_USER=chimera_app +# export DB_READER_AWS_IAM_AUTH_DB_USER=myapp_user # How It Works: # 1. The module strips any password from the DSN (including placeholders like $TOKEN) diff --git a/modules/database/go.mod b/modules/database/go.mod index a8b7fd94..4349ee35 100644 --- a/modules/database/go.mod +++ b/modules/database/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/database/v2 +module github.com/GoCodeAlone/modular/modules/database/v2 -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/aws/aws-sdk-go-v2/config v1.31.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 diff --git a/modules/database/go.sum b/modules/database/go.sum index 336daa0b..21a0bec2 100644 --- a/modules/database/go.sum +++ b/modules/database/go.sum @@ -5,8 +5,8 @@ filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= diff --git a/modules/database/integration_test.go b/modules/database/integration_test.go index f39a4ef7..e6280f8a 100644 --- a/modules/database/integration_test.go +++ b/modules/database/integration_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDatabaseModuleWithInstanceAwareConfiguration tests the module with instance-aware env configuration diff --git a/modules/database/interface_matching_test.go b/modules/database/interface_matching_test.go index bd867a26..5d11ceff 100644 --- a/modules/database/interface_matching_test.go +++ b/modules/database/interface_matching_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" diff --git a/modules/database/migrations.go b/modules/database/migrations.go index f4fa4b45..83404f36 100644 --- a/modules/database/migrations.go +++ b/modules/database/migrations.go @@ -8,7 +8,7 @@ import ( "sort" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/database/module.go b/modules/database/module.go index 7be805c2..bb6f1260 100644 --- a/modules/database/module.go +++ b/modules/database/module.go @@ -32,7 +32,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -406,6 +406,7 @@ func (l *lazyDefaultService) SetEventEmitter(emitter EventEmitter) { type Module struct { config *Config connections map[string]*sql.DB + connMu sync.RWMutex // Protects connections map services map[string]DatabaseService subject modular.Subject // For event observation subjectMu sync.RWMutex // Protects subject field from race conditions diff --git a/modules/database/module_test.go b/modules/database/module_test.go index 62117a8c..9a01d383 100644 --- a/modules/database/module_test.go +++ b/modules/database/module_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" // Import pure Go sqlite driver for testing diff --git a/modules/database/service.go b/modules/database/service.go index c969a8c3..a7e2a1bc 100644 --- a/modules/database/service.go +++ b/modules/database/service.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Define static errors diff --git a/modules/database/v2_interfaces.go b/modules/database/v2_interfaces.go new file mode 100644 index 00000000..8343d267 --- /dev/null +++ b/modules/database/v2_interfaces.go @@ -0,0 +1,165 @@ +package database + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface assertions. +var ( + _ modular.MetricsProvider = (*Module)(nil) + _ modular.Drainable = (*Module)(nil) + _ modular.Reloadable = (*Module)(nil) +) + +// CollectMetrics implements modular.MetricsProvider. +// It returns pool statistics from sql.DBStats for every connection. +func (m *Module) CollectMetrics(_ context.Context) modular.ModuleMetrics { + values := make(map[string]float64) + + m.connMu.RLock() + multipleConns := len(m.connections) > 1 + + for name, db := range m.connections { + stats := db.Stats() + prefix := "" + if multipleConns { + prefix = name + "." + } + values[prefix+"open_connections"] = float64(stats.OpenConnections) + values[prefix+"in_use"] = float64(stats.InUse) + values[prefix+"idle"] = float64(stats.Idle) + values[prefix+"wait_count"] = float64(stats.WaitCount) + values[prefix+"wait_duration_ms"] = float64(stats.WaitDuration.Milliseconds()) + values[prefix+"max_open"] = float64(stats.MaxOpenConnections) + } + m.connMu.RUnlock() + + return modular.ModuleMetrics{ + Name: Name, + Values: values, + } +} + +// PreStop implements modular.Drainable. +// It caps max open connections to the current in-use count (minimum 1) to +// allow active queries to finish while preventing new connections, then waits +// for active queries to complete. +// Note: SetMaxOpenConns(0) means unlimited in database/sql, so we use +// max(stats.InUse, 1) to actually restrict the pool. +func (m *Module) PreStop(ctx context.Context) error { + m.connMu.RLock() + m.logger.Info("Draining database connections", "count", len(m.connections)) + + for name, db := range m.connections { + stats := db.Stats() + cap := stats.InUse + if cap < 1 { + cap = 1 + } + db.SetMaxOpenConns(cap) + m.logger.Info("Capped max open connections for drain", "connection", name, "cap", cap) + } + m.connMu.RUnlock() + + // Wait for active queries to finish, respecting context deadline. + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + allIdle := true + m.connMu.RLock() + for _, db := range m.connections { + if db.Stats().InUse > 0 { + allIdle = false + break + } + } + m.connMu.RUnlock() + if allIdle { + m.logger.Info("All database connections drained") + return nil + } + + select { + case <-ctx.Done(): + m.logger.Warn("Drain timeout reached, proceeding with active connections") + return nil + case <-ticker.C: + // Check again + } + } +} + +// CanReload implements modular.Reloadable. +func (m *Module) CanReload() bool { + return true +} + +// ReloadTimeout implements modular.Reloadable. +func (m *Module) ReloadTimeout() time.Duration { + return 10 * time.Second +} + +// Reload implements modular.Reloadable. +// It applies pool configuration changes to existing connections without reconnecting. +func (m *Module) Reload(_ context.Context, changes []modular.ConfigChange) error { + for _, change := range changes { + // Match field paths like "MaxOpenConnections" or "connections..MaxOpenConnections" + field := change.FieldPath + parts := strings.Split(field, ".") + + // Determine target field name (last segment) + targetField := parts[len(parts)-1] + + // Determine which connections to apply to + var targetConns []string + if len(parts) >= 3 && parts[0] == "connections" { + // Scoped to a specific connection: connections.. + targetConns = []string{parts[1]} + } else { + // Apply to all connections + for name := range m.connections { + targetConns = append(targetConns, name) + } + } + + for _, connName := range targetConns { + db, ok := m.connections[connName] + if !ok { + continue + } + + switch targetField { + case "MaxOpenConnections": + if v, err := strconv.Atoi(change.NewValue); err == nil { + db.SetMaxOpenConns(v) + m.logger.Info("Reloaded MaxOpenConnections", "connection", connName, "value", v) + } + case "MaxIdleConnections": + if v, err := strconv.Atoi(change.NewValue); err == nil { + db.SetMaxIdleConns(v) + m.logger.Info("Reloaded MaxIdleConnections", "connection", connName, "value", v) + } + case "ConnectionMaxLifetime": + if v, err := time.ParseDuration(change.NewValue); err == nil { + db.SetConnMaxLifetime(v) + m.logger.Info("Reloaded ConnectionMaxLifetime", "connection", connName, "value", v) + } + case "ConnectionMaxIdleTime": + if v, err := time.ParseDuration(change.NewValue); err == nil { + db.SetConnMaxIdleTime(v) + m.logger.Info("Reloaded ConnectionMaxIdleTime", "connection", connName, "value", v) + } + default: + m.logger.Debug("Ignoring unrecognized config change", "field", fmt.Sprintf("%s (target: %s)", field, targetField)) + } + } + } + return nil +} diff --git a/modules/database/v2_interfaces_test.go b/modules/database/v2_interfaces_test.go new file mode 100644 index 00000000..5aa27216 --- /dev/null +++ b/modules/database/v2_interfaces_test.go @@ -0,0 +1,338 @@ +package database + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +func TestModule_CollectMetrics(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + metrics := module.CollectMetrics(ctx) + + assert.Equal(t, Name, metrics.Name) + assert.NotNil(t, metrics.Values) + + // With a single connection, keys should not be prefixed + expectedKeys := []string{ + "open_connections", + "in_use", + "idle", + "wait_count", + "wait_duration_ms", + "max_open", + } + for _, key := range expectedKeys { + _, exists := metrics.Values[key] + assert.True(t, exists, "expected metric key %q to exist", key) + } +} + +func TestModule_CollectMetrics_MultipleConnections(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + "secondary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + metrics := module.CollectMetrics(ctx) + + assert.Equal(t, Name, metrics.Name) + + // With multiple connections, keys should be prefixed with connection name + for _, prefix := range []string{"primary.", "secondary."} { + _, exists := metrics.Values[prefix+"open_connections"] + assert.True(t, exists, "expected metric key %q to exist", prefix+"open_connections") + } +} + +func TestModule_CollectMetrics_NoConnections(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "default", + Connections: map[string]*ConnectionConfig{}, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + + ctx := context.Background() + metrics := module.CollectMetrics(ctx) + + assert.Equal(t, Name, metrics.Name) + assert.Empty(t, metrics.Values) +} + +func TestModule_PreStop(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = module.PreStop(ctx) + assert.NoError(t, err) +} + +func TestModule_PreStop_NoConnections(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "default", + Connections: map[string]*ConnectionConfig{}, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + + ctx := context.Background() + err = module.PreStop(ctx) + assert.NoError(t, err) +} + +func TestModule_Reloadable(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + assert.True(t, module.CanReload()) + assert.Equal(t, 10*time.Second, module.ReloadTimeout()) +} + +func TestModule_Reload(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + MaxOpenConnections: 10, + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Reload with MaxOpenConnections change + changes := []modular.ConfigChange{ + { + FieldPath: "MaxOpenConnections", + NewValue: "20", + }, + { + FieldPath: "MaxIdleConnections", + NewValue: "5", + }, + { + FieldPath: "ConnectionMaxLifetime", + NewValue: "30m", + }, + { + FieldPath: "ConnectionMaxIdleTime", + NewValue: "5m", + }, + } + + err = module.Reload(ctx, changes) + assert.NoError(t, err) + + // Verify the pool settings were applied + db, exists := module.GetConnection("primary") + require.True(t, exists) + stats := db.Stats() + assert.Equal(t, 20, stats.MaxOpenConnections) +} + +func TestModule_Reload_ScopedConnection(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + MaxOpenConnections: 10, + }, + "secondary": { + Driver: "sqlite", + DSN: ":memory:", + MaxOpenConnections: 10, + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Reload only the primary connection's MaxOpenConnections + changes := []modular.ConfigChange{ + { + FieldPath: "connections.primary.MaxOpenConnections", + NewValue: "25", + }, + } + + err = module.Reload(ctx, changes) + assert.NoError(t, err) + + primaryDB, _ := module.GetConnection("primary") + assert.Equal(t, 25, primaryDB.Stats().MaxOpenConnections) + + secondaryDB, _ := module.GetConnection("secondary") + assert.Equal(t, 10, secondaryDB.Stats().MaxOpenConnections) +} + +func TestModule_Reload_UnrecognizedField(t *testing.T) { + module := NewModule() + app := NewMockApplication() + + config := &Config{ + Default: "primary", + Connections: map[string]*ConnectionConfig{ + "primary": { + Driver: "sqlite", + DSN: ":memory:", + }, + }, + } + + err := module.RegisterConfig(app) + require.NoError(t, err) + app.RegisterConfigSection("database", &MockConfigProvider{config: config}) + + err = module.Init(app) + require.NoError(t, err) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Unrecognized fields should be silently ignored + changes := []modular.ConfigChange{ + { + FieldPath: "SomeUnknownField", + NewValue: "value", + }, + } + + err = module.Reload(ctx, changes) + assert.NoError(t, err) +} + +func TestModule_InterfaceCompliance(t *testing.T) { + module := NewModule() + assert.Implements(t, (*modular.MetricsProvider)(nil), module) + assert.Implements(t, (*modular.Drainable)(nil), module) + assert.Implements(t, (*modular.Reloadable)(nil), module) +} diff --git a/modules/eventbus/README.md b/modules/eventbus/README.md index 988b5cff..c772dbe3 100644 --- a/modules/eventbus/README.md +++ b/modules/eventbus/README.md @@ -1,6 +1,6 @@ # EventBus Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/eventbus) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/eventbus.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/eventbus) The EventBus Module provides a publish-subscribe messaging system for Modular applications with support for multiple concurrent engines, topic-based routing, and flexible configuration. It enables decoupled communication between components through a powerful event-driven architecture. @@ -37,8 +37,8 @@ The EventBus Module provides a publish-subscribe messaging system for Modular ap ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventbus" ) // Register the eventbus module with your Modular application @@ -246,7 +246,7 @@ Register the collector with your Prometheus registry (global or custom): ```go import ( - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular/modules/eventbus" prom "github.com/prometheus/client_golang/prometheus" promhttp "github.com/prometheus/client_golang/prometheus/promhttp" "net/http" @@ -278,7 +278,7 @@ Start the exporter in a background goroutine. It periodically snapshots stats an ```go import ( "time" - "github.com/CrisisTextLine/modular/modules/eventbus" + "github.com/GoCodeAlone/modular/modules/eventbus" ) exporter, err := eventbus.NewDatadogStatsdExporter(eventBus, eventbus.DatadogExporterConfig{ diff --git a/modules/eventbus/bdd_context_test.go b/modules/eventbus/bdd_context_test.go index ba03a5c2..04551f67 100644 --- a/modules/eventbus/bdd_context_test.go +++ b/modules/eventbus/bdd_context_test.go @@ -4,7 +4,7 @@ import ( "context" "sync" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" cevent "github.com/cloudevents/sdk-go/v2/event" "github.com/google/uuid" diff --git a/modules/eventbus/bdd_core_initialization_test.go b/modules/eventbus/bdd_core_initialization_test.go index 48ee64e0..6d0afb86 100644 --- a/modules/eventbus/bdd_core_initialization_test.go +++ b/modules/eventbus/bdd_core_initialization_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/bdd_engine_error_test.go b/modules/eventbus/bdd_engine_error_test.go index d8e3f201..54af40c3 100644 --- a/modules/eventbus/bdd_engine_error_test.go +++ b/modules/eventbus/bdd_engine_error_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/bdd_multi_engine_test.go b/modules/eventbus/bdd_multi_engine_test.go index a639fb5a..f23fc2f4 100644 --- a/modules/eventbus/bdd_multi_engine_test.go +++ b/modules/eventbus/bdd_multi_engine_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/bdd_registration_test.go b/modules/eventbus/bdd_registration_test.go index 5470e648..26074bd3 100644 --- a/modules/eventbus/bdd_registration_test.go +++ b/modules/eventbus/bdd_registration_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/eventbus/bdd_tenant_isolation_test.go b/modules/eventbus/bdd_tenant_isolation_test.go index 470d8099..05954582 100644 --- a/modules/eventbus/bdd_tenant_isolation_test.go +++ b/modules/eventbus/bdd_tenant_isolation_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ============================================================================== diff --git a/modules/eventbus/concurrency_test.go b/modules/eventbus/concurrency_test.go index 7a89bb2c..06386e6e 100644 --- a/modules/eventbus/concurrency_test.go +++ b/modules/eventbus/concurrency_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Baseline stress test in drop mode to ensure no starvation of async subscribers. diff --git a/modules/eventbus/custom_memory.go b/modules/eventbus/custom_memory.go index 22bf3b70..cdedc107 100644 --- a/modules/eventbus/custom_memory.go +++ b/modules/eventbus/custom_memory.go @@ -193,7 +193,7 @@ func (c *CustomMemoryEventBus) Start(ctx context.Context) error { return nil } - c.ctx, c.cancel = context.WithCancel(ctx) + c.ctx, c.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in c.cancel and called in Stop() // Start metrics collection if enabled if c.config.EnableMetrics { diff --git a/modules/eventbus/durable_memory.go b/modules/eventbus/durable_memory.go index 00cf7db9..f03b70b1 100644 --- a/modules/eventbus/durable_memory.go +++ b/modules/eventbus/durable_memory.go @@ -216,7 +216,7 @@ func (d *DurableMemoryEventBus) Start(ctx context.Context) error { if d.isStarted { return nil } - d.ctx, d.cancel = context.WithCancel(ctx) + d.ctx, d.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in d.cancel and called in Stop() d.isStarted = true return nil } diff --git a/modules/eventbus/durable_memory_test.go b/modules/eventbus/durable_memory_test.go index 5bc71067..d190c5e4 100644 --- a/modules/eventbus/durable_memory_test.go +++ b/modules/eventbus/durable_memory_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -48,8 +48,8 @@ func publishN(t *testing.T, module *EventBusModule, topic string, n int) int { // durable engine the publisher blocks until the subscriber processes each batch. func TestDurableMemoryNoEventLoss(t *testing.T) { const ( - topic = "durable.no-loss" - total = 200 + topic = "durable.no-loss" + total = 200 queueDepth = 20 // intentionally small to exercise backpressure ) diff --git a/modules/eventbus/go.mod b/modules/eventbus/go.mod index 04a9d594..9dfa1919 100644 --- a/modules/eventbus/go.mod +++ b/modules/eventbus/go.mod @@ -1,13 +1,12 @@ -module github.com/CrisisTextLine/modular/modules/eventbus/v2 +module github.com/GoCodeAlone/modular/modules/eventbus/v2 -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 github.com/DataDog/datadog-go/v5 v5.4.0 + github.com/GoCodeAlone/modular v1.12.2 github.com/IBM/sarama v1.45.2 github.com/aws/aws-sdk-go-v2/config v1.31.0 github.com/aws/aws-sdk-go-v2/service/kinesis v1.38.0 diff --git a/modules/eventbus/go.sum b/modules/eventbus/go.sum index e38aba0d..97a94dc1 100644 --- a/modules/eventbus/go.sum +++ b/modules/eventbus/go.sum @@ -1,11 +1,9 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= -github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 h1:SSeu7rjuECDgFa+iNyndn94YPQxffHxJgfR7U4psz6E= -github.com/CrisisTextLine/modular/modules/eventbus v1.7.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c= github.com/DataDog/datadog-go/v5 v5.4.0 h1:Ea3eXUVwrVV28F/fo3Dr3aa+TL/Z7Xi6SUPKW8L99aI= github.com/DataDog/datadog-go/v5 v5.4.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/IBM/sarama v1.45.2 h1:8m8LcMCu3REcwpa7fCP6v2fuPuzVwXDAM2DOv3CBrKw= github.com/IBM/sarama v1.45.2/go.mod h1:ppaoTcVdGv186/z6MEKsMm70A5fwJfRTpstI37kVn3Y= github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= diff --git a/modules/eventbus/kafka.go b/modules/eventbus/kafka.go index 0439fb94..c9fa3626 100644 --- a/modules/eventbus/kafka.go +++ b/modules/eventbus/kafka.go @@ -235,7 +235,7 @@ func (k *KafkaEventBus) Start(ctx context.Context) error { return nil } - k.ctx, k.cancel = context.WithCancel(ctx) + k.ctx, k.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in k.cancel and called in Stop() k.isStarted = true return nil } diff --git a/modules/eventbus/kafka_test.go b/modules/eventbus/kafka_test.go index 42838da3..7c08cda3 100644 --- a/modules/eventbus/kafka_test.go +++ b/modules/eventbus/kafka_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "github.com/CrisisTextLine/modular/modules/eventbus/mocks" + "github.com/GoCodeAlone/modular/modules/eventbus/v2/mocks" ) // newTestKafkaEventBus creates a KafkaEventBus wired to a mock producer, diff --git a/modules/eventbus/kinesis.go b/modules/eventbus/kinesis.go index b5b21e73..2c4ff6db 100644 --- a/modules/eventbus/kinesis.go +++ b/modules/eventbus/kinesis.go @@ -1,6 +1,6 @@ package eventbus -//go:generate mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/CrisisTextLine/modular/modules/eventbus KinesisClient +//go:generate mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/GoCodeAlone/modular/modules/eventbus KinesisClient import ( "context" @@ -210,7 +210,7 @@ func (k *KinesisEventBus) Start(ctx context.Context) error { if k.config.PollInterval <= 0 { k.config.PollInterval = DefaultKinesisPollInterval } - k.ctx, k.cancel = context.WithCancel(ctx) + k.ctx, k.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in k.cancel and called in Stop() k.isStarted = true return nil } diff --git a/modules/eventbus/kinesis_test.go b/modules/eventbus/kinesis_test.go index 2694627b..69e93d9c 100644 --- a/modules/eventbus/kinesis_test.go +++ b/modules/eventbus/kinesis_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "github.com/CrisisTextLine/modular/modules/eventbus/mocks" + "github.com/GoCodeAlone/modular/modules/eventbus/v2/mocks" ) // newTestKinesisEventBus creates a KinesisEventBus wired to a mock client, diff --git a/modules/eventbus/memory.go b/modules/eventbus/memory.go index 2da27898..3d1f2272 100644 --- a/modules/eventbus/memory.go +++ b/modules/eventbus/memory.go @@ -7,7 +7,7 @@ import ( "sync/atomic" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/google/uuid" ) @@ -113,7 +113,7 @@ func (m *MemoryEventBus) Start(ctx context.Context) error { return nil } - m.ctx, m.cancel = context.WithCancel(ctx) + m.ctx, m.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in m.cancel and called in Stop() // Initialize worker pool for async event handling. // Buffer size is MaxEventQueueSize so the task queue can absorb bursts diff --git a/modules/eventbus/memory_buffer_test.go b/modules/eventbus/memory_buffer_test.go index 74baa497..ba01761a 100644 --- a/modules/eventbus/memory_buffer_test.go +++ b/modules/eventbus/memory_buffer_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,7 +22,7 @@ func TestWorkerPoolBufferSize(t *testing.T) { app := newMockApp() cfg := &EventBusConfig{ Engine: "memory", - WorkerCount: 1, // only one worker — intentionally slow + WorkerCount: 1, // only one worker — intentionally slow DefaultEventBufferSize: 64, MaxEventQueueSize: 50, // queue depth for the worker pool task queue DeliveryMode: "drop", diff --git a/modules/eventbus/memory_race_test.go b/modules/eventbus/memory_race_test.go index d9fcab21..c9ec18fd 100644 --- a/modules/eventbus/memory_race_test.go +++ b/modules/eventbus/memory_race_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestMemoryEventBusHighConcurrencyRace is a stress test intended to be run with -race. diff --git a/modules/eventbus/metrics_exporters_datadog_test.go b/modules/eventbus/metrics_exporters_datadog_test.go index 0c6ce427..e364e4e5 100644 --- a/modules/eventbus/metrics_exporters_datadog_test.go +++ b/modules/eventbus/metrics_exporters_datadog_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDatadogStatsdExporterBasic spins up an in-process UDP listener to capture diff --git a/modules/eventbus/mocks/mock_kinesis.go b/modules/eventbus/mocks/mock_kinesis.go index e70e9ad3..a2441461 100644 --- a/modules/eventbus/mocks/mock_kinesis.go +++ b/modules/eventbus/mocks/mock_kinesis.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/CrisisTextLine/modular/modules/eventbus (interfaces: KinesisClient) +// Source: github.com/GoCodeAlone/modular/modules/eventbus (interfaces: KinesisClient) // // Generated by this command: // -// mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/CrisisTextLine/modular/modules/eventbus KinesisClient +// mockgen -destination=mocks/mock_kinesis.go -package=mocks github.com/GoCodeAlone/modular/modules/eventbus KinesisClient // // Package mocks is a generated GoMock package. diff --git a/modules/eventbus/module.go b/modules/eventbus/module.go index 9edfbc44..813c34fd 100644 --- a/modules/eventbus/module.go +++ b/modules/eventbus/module.go @@ -113,7 +113,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" cevent "github.com/cloudevents/sdk-go/v2/event" "github.com/google/uuid" diff --git a/modules/eventbus/module_test.go b/modules/eventbus/module_test.go index 07ed7a80..76924a05 100644 --- a/modules/eventbus/module_test.go +++ b/modules/eventbus/module_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/eventbus/nats.go b/modules/eventbus/nats.go index 95ef8639..c667c84f 100644 --- a/modules/eventbus/nats.go +++ b/modules/eventbus/nats.go @@ -177,7 +177,7 @@ func (n *NatsEventBus) Start(ctx context.Context) error { return ErrNATSConnectionNotEstablished } - n.ctx, n.cancel = context.WithCancel(ctx) + n.ctx, n.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in n.cancel and called in Stop() n.isStarted = true return nil } diff --git a/modules/eventbus/publish_options_test.go b/modules/eventbus/publish_options_test.go index 6850f31d..ba219210 100644 --- a/modules/eventbus/publish_options_test.go +++ b/modules/eventbus/publish_options_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/eventbus/redis.go b/modules/eventbus/redis.go index 6e0a62e2..7d181d62 100644 --- a/modules/eventbus/redis.go +++ b/modules/eventbus/redis.go @@ -140,7 +140,7 @@ func (r *RedisEventBus) Start(ctx context.Context) error { return fmt.Errorf("failed to connect to Redis: %w", err) } - r.ctx, r.cancel = context.WithCancel(ctx) + r.ctx, r.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in r.cancel and called in Stop() r.isStarted = true return nil } diff --git a/modules/eventbus/v2_interfaces.go b/modules/eventbus/v2_interfaces.go new file mode 100644 index 00000000..d60cf969 --- /dev/null +++ b/modules/eventbus/v2_interfaces.go @@ -0,0 +1,44 @@ +package eventbus + +import ( + "context" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface assertions. +var ( + _ modular.MetricsProvider = (*EventBusModule)(nil) + _ modular.Drainable = (*EventBusModule)(nil) +) + +// CollectMetrics implements modular.MetricsProvider. +// It exposes delivery statistics and topology counts for external monitoring. +func (m *EventBusModule) CollectMetrics(_ context.Context) modular.ModuleMetrics { + delivered, dropped := m.Stats() + + topics := m.Topics() + var subscriberTotal int + for _, t := range topics { + subscriberTotal += m.SubscriberCount(t) + } + + return modular.ModuleMetrics{ + Name: m.Name(), + Values: map[string]float64{ + "delivered_count": float64(delivered), + "dropped_count": float64(dropped), + "topic_count": float64(len(topics)), + "subscriber_count": float64(subscriberTotal), + }, + } +} + +// PreStop implements modular.Drainable. +// It logs the drain intent. Actual resource cleanup is handled by Stop(). +func (m *EventBusModule) PreStop(_ context.Context) error { + if m.logger != nil { + m.logger.Info("EventBus drain phase starting — no new publishes will be accepted after Stop") + } + return nil +} diff --git a/modules/eventbus/v2_interfaces_test.go b/modules/eventbus/v2_interfaces_test.go new file mode 100644 index 00000000..c43e786b --- /dev/null +++ b/modules/eventbus/v2_interfaces_test.go @@ -0,0 +1,97 @@ +package eventbus + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helper that creates, inits, and starts an EventBusModule. +func setupModule(t *testing.T) *EventBusModule { + t.Helper() + module := NewModule().(*EventBusModule) + app := newMockApp() + require.NoError(t, module.RegisterConfig(app)) + require.NoError(t, module.Init(app)) + require.NoError(t, module.Start(context.Background())) + return module +} + +func TestEventBusModule_CollectMetrics(t *testing.T) { + module := setupModule(t) + defer func() { _ = module.Stop(context.Background()) }() + + ctx := context.Background() + + // Before any activity the counters should be zero. + metrics := module.CollectMetrics(ctx) + assert.Equal(t, module.Name(), metrics.Name) + assert.Equal(t, float64(0), metrics.Values["delivered_count"]) + assert.Equal(t, float64(0), metrics.Values["dropped_count"]) + assert.Equal(t, float64(0), metrics.Values["topic_count"]) + assert.Equal(t, float64(0), metrics.Values["subscriber_count"]) + + // Subscribe + publish so counters move. + received := make(chan struct{}, 1) + sub, err := module.Subscribe(ctx, "metrics.test", func(_ context.Context, _ Event) error { + received <- struct{}{} + return nil + }) + require.NoError(t, err) + + require.NoError(t, module.Publish(ctx, "metrics.test", map[string]any{"k": "v"})) + + select { + case <-received: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for event delivery") + } + + metrics = module.CollectMetrics(ctx) + assert.Equal(t, float64(1), metrics.Values["delivered_count"]) + assert.Equal(t, float64(0), metrics.Values["dropped_count"]) + assert.Equal(t, float64(1), metrics.Values["topic_count"]) + assert.Equal(t, float64(1), metrics.Values["subscriber_count"]) + + _ = module.Unsubscribe(ctx, sub) +} + +func TestEventBusModule_CollectMetrics_InterfaceCompliance(t *testing.T) { + var _ modular.MetricsProvider = (*EventBusModule)(nil) +} + +func TestEventBusModule_PreStop(t *testing.T) { + module := setupModule(t) + defer func() { _ = module.Stop(context.Background()) }() + + // PreStop should succeed without error. + err := module.PreStop(context.Background()) + assert.NoError(t, err) + + // Module should still be operational after PreStop (Stop handles actual shutdown). + ctx := context.Background() + received := make(chan struct{}, 1) + sub, err := module.Subscribe(ctx, "prestop.test", func(_ context.Context, _ Event) error { + received <- struct{}{} + return nil + }) + require.NoError(t, err) + + require.NoError(t, module.Publish(ctx, "prestop.test", map[string]any{"a": 1})) + + select { + case <-received: + case <-time.After(2 * time.Second): + t.Fatal("timed out — module should still work after PreStop") + } + + _ = module.Unsubscribe(ctx, sub) +} + +func TestEventBusModule_PreStop_InterfaceCompliance(t *testing.T) { + var _ modular.Drainable = (*EventBusModule)(nil) +} diff --git a/modules/eventlogger/README.md b/modules/eventlogger/README.md index f1e21a68..781914d7 100644 --- a/modules/eventlogger/README.md +++ b/modules/eventlogger/README.md @@ -1,6 +1,6 @@ # EventLogger Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/eventlogger.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/eventlogger) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/eventlogger.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/eventlogger) The EventLogger Module provides structured logging capabilities for Observer pattern events in Modular applications. It acts as an Observer that can be registered with any Subject to log events to various output targets including console, files, and syslog. @@ -19,8 +19,8 @@ The EventLogger Module provides structured logging capabilities for Observer pat ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventlogger" ) // Register the eventlogger module with your Modular application @@ -83,8 +83,8 @@ eventlogger: ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/eventlogger" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/eventlogger" ) func main() { diff --git a/modules/eventlogger/bdd_buffer_management_test.go b/modules/eventlogger/bdd_buffer_management_test.go index a5db5281..04eca074 100644 --- a/modules/eventlogger/bdd_buffer_management_test.go +++ b/modules/eventlogger/bdd_buffer_management_test.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/bdd_core_eventlogger_test.go b/modules/eventlogger/bdd_core_eventlogger_test.go index 48e6a709..e96dd6a2 100644 --- a/modules/eventlogger/bdd_core_eventlogger_test.go +++ b/modules/eventlogger/bdd_core_eventlogger_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Configuration creation helpers diff --git a/modules/eventlogger/bdd_error_handling_test.go b/modules/eventlogger/bdd_error_handling_test.go index fc8a7c8d..80c87e97 100644 --- a/modules/eventlogger/bdd_error_handling_test.go +++ b/modules/eventlogger/bdd_error_handling_test.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Error handling step implementations diff --git a/modules/eventlogger/bdd_event_observation_test.go b/modules/eventlogger/bdd_event_observation_test.go index 24c4adf4..df12467f 100644 --- a/modules/eventlogger/bdd_event_observation_test.go +++ b/modules/eventlogger/bdd_event_observation_test.go @@ -5,7 +5,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation setup and step implementations diff --git a/modules/eventlogger/bdd_output_targets_test.go b/modules/eventlogger/bdd_output_targets_test.go index fbf97ff2..8758a93e 100644 --- a/modules/eventlogger/bdd_output_targets_test.go +++ b/modules/eventlogger/bdd_output_targets_test.go @@ -118,8 +118,9 @@ func (ctx *EventLoggerBDDTestContext) iHaveAnEventLoggerWithMetadataInclusionEna return err } - // Create config with metadata inclusion enabled (already enabled in console config) - config := ctx.createConsoleConfig(10) + // Create config with metadata inclusion enabled (already enabled in console config). + // Buffer must be large enough to absorb framework lifecycle events during Init/Start. + config := ctx.createConsoleConfig(50) // Create application with the config err = ctx.createApplicationWithConfig(config) diff --git a/modules/eventlogger/bdd_test_shared_test.go b/modules/eventlogger/bdd_test_shared_test.go index f158862b..06feb891 100644 --- a/modules/eventlogger/bdd_test_shared_test.go +++ b/modules/eventlogger/bdd_test_shared_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/blacklist_filtering_test.go b/modules/eventlogger/blacklist_filtering_test.go index 6ccbc413..f6cc1ddd 100644 --- a/modules/eventlogger/blacklist_filtering_test.go +++ b/modules/eventlogger/blacklist_filtering_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/eventlogger/go.mod b/modules/eventlogger/go.mod index e67fa718..aca77abb 100644 --- a/modules/eventlogger/go.mod +++ b/modules/eventlogger/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/eventlogger +module github.com/GoCodeAlone/modular/modules/eventlogger -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/eventlogger/go.sum b/modules/eventlogger/go.sum index b8bc6967..76b6bb8a 100644 --- a/modules/eventlogger/go.sum +++ b/modules/eventlogger/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/eventlogger/module.go b/modules/eventlogger/module.go index c365c666..4721693b 100644 --- a/modules/eventlogger/module.go +++ b/modules/eventlogger/module.go @@ -120,7 +120,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -377,6 +377,28 @@ func (m *EventLoggerModule) emitStartupOperationalEvents(ctx context.Context, sy } } +// PreStop implements the modular.Drainable interface. +// It signals the event logger to flush pending events before the full Stop phase. +func (m *EventLoggerModule) PreStop(ctx context.Context) error { + m.mutex.RLock() + started := m.started + logger := m.logger + m.mutex.RUnlock() + + if !started { + return nil + } + + if logger != nil { + logger.Info("Event logger drain phase starting") + } + + // Flush all output targets to ensure buffered data is written + m.flushOutputs() + + return nil +} + // Stop stops the event logger processing. func (m *EventLoggerModule) Stop(ctx context.Context) error { m.mutex.Lock() diff --git a/modules/eventlogger/module_test.go b/modules/eventlogger/module_test.go index bfc022fb..d49dce71 100644 --- a/modules/eventlogger/module_test.go +++ b/modules/eventlogger/module_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/output.go b/modules/eventlogger/output.go index 1ef64b3f..e7467c6f 100644 --- a/modules/eventlogger/output.go +++ b/modules/eventlogger/output.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // OutputTarget defines the interface for event log output targets. diff --git a/modules/eventlogger/race_condition_test.go b/modules/eventlogger/race_condition_test.go index 28dab951..efd43190 100644 --- a/modules/eventlogger/race_condition_test.go +++ b/modules/eventlogger/race_condition_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/eventlogger/regression_test.go b/modules/eventlogger/regression_test.go index 8d563bce..3c443469 100644 --- a/modules/eventlogger/regression_test.go +++ b/modules/eventlogger/regression_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -155,7 +155,7 @@ func TestEventLogger_EarlyLifecycleEventsDoNotError(t *testing.T) { func TestEventLogger_SynchronousStartupConfigFlag(t *testing.T) { logger := &capturingLogger{} app := modular.NewObservableApplication(modular.NewStdConfigProvider(struct{}{}), logger) - cfg := &EventLoggerConfig{Enabled: true, LogLevel: "INFO", Format: "structured", BufferSize: 5, FlushInterval: 100 * time.Millisecond, StartupSync: true, OutputTargets: []OutputTargetConfig{{Type: "console", Level: "INFO", Format: "structured", Console: &ConsoleTargetConfig{UseColor: false, Timestamps: false}}}} + cfg := &EventLoggerConfig{Enabled: true, LogLevel: "INFO", Format: "structured", BufferSize: 50, FlushInterval: 100 * time.Millisecond, StartupSync: true, OutputTargets: []OutputTargetConfig{{Type: "console", Level: "INFO", Format: "structured", Console: &ConsoleTargetConfig{UseColor: false, Timestamps: false}}}} app.RegisterConfigSection(ModuleName, modular.NewStdConfigProvider(cfg)) mod := NewModule().(*EventLoggerModule) app.RegisterModule(mod) @@ -165,7 +165,9 @@ func TestEventLogger_SynchronousStartupConfigFlag(t *testing.T) { if err := app.Start(); err != nil { t.Fatalf("start failed: %v", err) } - // Without sleep, attempt to emit a test event and ensure no ErrLoggerNotStarted + // Without sleep, attempt to emit a test event and ensure no ErrLoggerNotStarted. + // BufferSize must be large enough to absorb framework lifecycle events + // (phase changes, service registrations, etc.) that are emitted during Init/Start. evt := modular.NewCloudEvent("sync.startup.test", "test", nil, nil) if err := mod.OnEvent(context.Background(), evt); err != nil { t.Fatalf("OnEvent failed unexpectedly: %v", err) diff --git a/modules/eventlogger/syslog_output_stub.go b/modules/eventlogger/syslog_output_stub.go index f8a146b7..d4e009dd 100644 --- a/modules/eventlogger/syslog_output_stub.go +++ b/modules/eventlogger/syslog_output_stub.go @@ -6,7 +6,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // SyslogTarget stub for unsupported platforms. diff --git a/modules/eventlogger/syslog_output_unix.go b/modules/eventlogger/syslog_output_unix.go index a9171d9f..67e213e3 100644 --- a/modules/eventlogger/syslog_output_unix.go +++ b/modules/eventlogger/syslog_output_unix.go @@ -7,7 +7,7 @@ import ( "fmt" "log/syslog" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // SyslogTarget outputs events to syslog (supported on Unix-like systems). diff --git a/modules/eventlogger/v2_interfaces_test.go b/modules/eventlogger/v2_interfaces_test.go new file mode 100644 index 00000000..48302e8a --- /dev/null +++ b/modules/eventlogger/v2_interfaces_test.go @@ -0,0 +1,38 @@ +package eventlogger + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/modular" +) + +// TestEventLoggerModule_ImplementsDrainable verifies that EventLoggerModule +// satisfies the modular.Drainable interface at compile time. +func TestEventLoggerModule_ImplementsDrainable(t *testing.T) { + var _ modular.Drainable = (*EventLoggerModule)(nil) +} + +// TestEventLoggerModule_PreStop_NotStarted verifies PreStop returns nil +// when the module has not been started. +func TestEventLoggerModule_PreStop_NotStarted(t *testing.T) { + m := &EventLoggerModule{name: ModuleName} + if err := m.PreStop(context.Background()); err != nil { + t.Fatalf("PreStop on unstarted module should return nil, got: %v", err) + } +} + +// TestEventLoggerModule_PreStop_Started verifies PreStop flushes outputs +// and returns nil when the module is running. +func TestEventLoggerModule_PreStop_Started(t *testing.T) { + m := &EventLoggerModule{ + name: ModuleName, + started: true, + logger: &testLogger{}, + outputs: []OutputTarget{}, + } + + if err := m.PreStop(context.Background()); err != nil { + t.Fatalf("PreStop on started module should return nil, got: %v", err) + } +} diff --git a/modules/httpclient/README.md b/modules/httpclient/README.md index 209d33bb..0f43ced3 100644 --- a/modules/httpclient/README.md +++ b/modules/httpclient/README.md @@ -1,6 +1,6 @@ # HTTP Client Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpclient) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpclient.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpclient) This module provides a configurable HTTP client service that can be used by other modules in the modular framework. It supports configurable connection pooling, timeouts, and optional verbose logging of HTTP requests and responses. @@ -92,9 +92,9 @@ func (m *ReverseProxyModule) Constructor() modular.ModuleConstructor { package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/httpclient" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/httpclient" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) func main() { diff --git a/modules/httpclient/bdd_core_httpclient_test.go b/modules/httpclient/bdd_core_httpclient_test.go index 1607add8..a32e93a3 100644 --- a/modules/httpclient/bdd_core_httpclient_test.go +++ b/modules/httpclient/bdd_core_httpclient_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/httpclient/bdd_events_test.go b/modules/httpclient/bdd_events_test.go index 6e9796d6..b7d5196e 100644 --- a/modules/httpclient/bdd_events_test.go +++ b/modules/httpclient/bdd_events_test.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event Observation BDD Test Steps diff --git a/modules/httpclient/go.mod b/modules/httpclient/go.mod index a7724e4f..64d28c73 100644 --- a/modules/httpclient/go.mod +++ b/modules/httpclient/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/httpclient +module github.com/GoCodeAlone/modular/modules/httpclient -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/httpclient/go.sum b/modules/httpclient/go.sum index b8bc6967..76b6bb8a 100644 --- a/modules/httpclient/go.sum +++ b/modules/httpclient/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/httpclient/logger.go b/modules/httpclient/logger.go index a45c2d09..08a9cb4b 100644 --- a/modules/httpclient/logger.go +++ b/modules/httpclient/logger.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // sanitizeForFilename replaces unsafe filename characters and ensures no directory traversal or special segments are allowed. diff --git a/modules/httpclient/logger_test.go b/modules/httpclient/logger_test.go index ce3e3cda..563a0146 100644 --- a/modules/httpclient/logger_test.go +++ b/modules/httpclient/logger_test.go @@ -116,7 +116,7 @@ func TestSanitizeForFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := sanitizeForFilename(tt.input) - + // For the long URL test, we need to check length separately if tt.name == "very long URL" { assert.LessOrEqual(t, len(result), 100, "result should be at most 100 characters") diff --git a/modules/httpclient/module.go b/modules/httpclient/module.go index 51567511..9846c270 100644 --- a/modules/httpclient/module.go +++ b/modules/httpclient/module.go @@ -126,7 +126,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/httpclient/module_test.go b/modules/httpclient/module_test.go index 7b54ea62..ba1088bd 100644 --- a/modules/httpclient/module_test.go +++ b/modules/httpclient/module_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -76,16 +76,16 @@ func (m *MockApplication) GetService(name string, target any) error { } // Add other required methods to satisfy the interface -func (m *MockApplication) Name() string { return "mock-app" } -func (m *MockApplication) IsInitializing() bool { return false } -func (m *MockApplication) IsStarting() bool { return false } -func (m *MockApplication) IsStopping() bool { return false } -func (m *MockApplication) RegisterModule(module modular.Module) {} -func (m *MockApplication) Run() error { return nil } -func (m *MockApplication) Shutdown(ctx context.Context) error { return nil } -func (m *MockApplication) Init() error { return nil } -func (m *MockApplication) Start() error { return nil } -func (m *MockApplication) Stop() error { return nil } +func (m *MockApplication) Name() string { return "mock-app" } +func (m *MockApplication) IsInitializing() bool { return false } +func (m *MockApplication) IsStarting() bool { return false } +func (m *MockApplication) IsStopping() bool { return false } +func (m *MockApplication) RegisterModule(module modular.Module) {} +func (m *MockApplication) Run() error { return nil } +func (m *MockApplication) Shutdown(ctx context.Context) error { return nil } +func (m *MockApplication) Init() error { return nil } +func (m *MockApplication) Start() error { return nil } +func (m *MockApplication) Stop() error { return nil } // Newly added methods to satisfy updated modular.Application interface func (m *MockApplication) Context() context.Context { return context.Background() } diff --git a/modules/httpclient/service_dependency_test.go b/modules/httpclient/service_dependency_test.go index e3b60698..ddc8d837 100644 --- a/modules/httpclient/service_dependency_test.go +++ b/modules/httpclient/service_dependency_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/httpserver/README.md b/modules/httpserver/README.md index 7bd2b25b..6c7e2fba 100644 --- a/modules/httpserver/README.md +++ b/modules/httpserver/README.md @@ -1,6 +1,6 @@ # HTTP Server Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/httpserver) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/httpserver.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/httpserver) This module provides HTTP/HTTPS server capabilities for the modular framework. It handles listening on a specified port, TLS configuration, and server timeouts. @@ -44,10 +44,10 @@ This module works with other modules in the application: package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/httpserver" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/reverseproxy" ) func main() { diff --git a/modules/httpserver/bdd_core_httpserver_test.go b/modules/httpserver/bdd_core_httpserver_test.go index 8760431c..c958792e 100644 --- a/modules/httpserver/bdd_core_httpserver_test.go +++ b/modules/httpserver/bdd_core_httpserver_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/httpserver/bdd_events_test.go b/modules/httpserver/bdd_events_test.go index 5836824b..15154d85 100644 --- a/modules/httpserver/bdd_events_test.go +++ b/modules/httpserver/bdd_events_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/httpserver/certificate_service_test.go b/modules/httpserver/certificate_service_test.go index 6ae432af..37d61fee 100644 --- a/modules/httpserver/certificate_service_test.go +++ b/modules/httpserver/certificate_service_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockCertificateService implements CertificateService for testing diff --git a/modules/httpserver/go.mod b/modules/httpserver/go.mod index d690ca83..bce327f1 100644 --- a/modules/httpserver/go.mod +++ b/modules/httpserver/go.mod @@ -1,9 +1,9 @@ -module github.com/CrisisTextLine/modular/modules/httpserver +module github.com/GoCodeAlone/modular/modules/httpserver -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/stretchr/testify v1.11.1 diff --git a/modules/httpserver/go.sum b/modules/httpserver/go.sum index b8bc6967..76b6bb8a 100644 --- a/modules/httpserver/go.sum +++ b/modules/httpserver/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/httpserver/module.go b/modules/httpserver/module.go index 3ef0c918..03aa5469 100644 --- a/modules/httpserver/module.go +++ b/modules/httpserver/module.go @@ -43,7 +43,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -93,6 +93,7 @@ type HTTPServerModule struct { started bool certificateService CertificateService subject modular.Subject // For event observation (guarded by mu) + draining bool // Set by PreStop to signal drain phase mu sync.RWMutex } diff --git a/modules/httpserver/module_test.go b/modules/httpserver/module_test.go index dcd69e98..72092605 100644 --- a/modules/httpserver/module_test.go +++ b/modules/httpserver/module_test.go @@ -22,7 +22,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/httpserver/v2_interfaces.go b/modules/httpserver/v2_interfaces.go new file mode 100644 index 00000000..d2380e88 --- /dev/null +++ b/modules/httpserver/v2_interfaces.go @@ -0,0 +1,121 @@ +package httpserver + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time interface assertions for v2 enhancement interfaces. +var ( + _ modular.Drainable = (*HTTPServerModule)(nil) + _ modular.Reloadable = (*HTTPServerModule)(nil) + _ modular.MetricsProvider = (*HTTPServerModule)(nil) +) + +// PreStop signals that the server is entering the drain phase. +// The actual graceful shutdown (http.Server.Shutdown) happens in Stop(). +// PreStop sets the draining flag so middleware or health checks can +// report the server as unhealthy during the drain window. +func (m *HTTPServerModule) PreStop(ctx context.Context) error { + m.mu.Lock() + m.draining = true + m.mu.Unlock() + if m.logger != nil { + m.logger.Info("HTTP server entering drain phase") + } + return nil +} + +// CanReload reports whether the module can currently accept a reload. +// Returns true only when the server has been started and is running. +func (m *HTTPServerModule) CanReload() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.started +} + +// ReloadTimeout returns the maximum duration allowed for a reload operation. +func (m *HTTPServerModule) ReloadTimeout() time.Duration { + return 5 * time.Second +} + +// Reload applies configuration changes to the running HTTP server. +// Supported fields: ReadTimeout, WriteTimeout, IdleTimeout. +// Note: http.Server timeout fields are not safe for concurrent mutation on a +// running server, so only the config is updated here. The new values take +// effect if the server is restarted. +func (m *HTTPServerModule) Reload(_ context.Context, changes []modular.ConfigChange) error { + m.mu.RLock() + if !m.started || m.server == nil { + m.mu.RUnlock() + return ErrServerNotStarted + } + m.mu.RUnlock() + + for _, change := range changes { + field := change.FieldPath + // Normalise: accept both dotted paths (e.g. "httpserver.ReadTimeout") + // and bare field names. + if idx := strings.LastIndex(field, "."); idx >= 0 { + field = field[idx+1:] + } + field = strings.ToLower(field) + + switch field { + case "readtimeout", "read_timeout": + d, err := time.ParseDuration(change.NewValue) + if err != nil { + return fmt.Errorf("invalid ReadTimeout value %q: %w", change.NewValue, err) + } + m.mu.Lock() + m.config.ReadTimeout = d + m.mu.Unlock() + + case "writetimeout", "write_timeout": + d, err := time.ParseDuration(change.NewValue) + if err != nil { + return fmt.Errorf("invalid WriteTimeout value %q: %w", change.NewValue, err) + } + m.mu.Lock() + m.config.WriteTimeout = d + m.mu.Unlock() + + case "idletimeout", "idle_timeout": + d, err := time.ParseDuration(change.NewValue) + if err != nil { + return fmt.Errorf("invalid IdleTimeout value %q: %w", change.NewValue, err) + } + m.mu.Lock() + m.config.IdleTimeout = d + m.mu.Unlock() + } + } + + return nil +} + +// CollectMetrics returns operational metrics for the HTTP server module. +func (m *HTTPServerModule) CollectMetrics(_ context.Context) modular.ModuleMetrics { + m.mu.RLock() + started := 0.0 + if m.started { + started = 1.0 + } + port := 0.0 + if m.config != nil { + port = float64(m.config.Port) + } + m.mu.RUnlock() + + return modular.ModuleMetrics{ + Name: ModuleName, + Values: map[string]float64{ + "started": started, + "port": port, + }, + } +} diff --git a/modules/httpserver/v2_interfaces_test.go b/modules/httpserver/v2_interfaces_test.go new file mode 100644 index 00000000..e3aa54d6 --- /dev/null +++ b/modules/httpserver/v2_interfaces_test.go @@ -0,0 +1,171 @@ +package httpserver + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newTestModule returns a minimally configured HTTPServerModule for unit tests. +func newTestModule(t *testing.T) *HTTPServerModule { + t.Helper() + logger := &MockLogger{} + // Register logger mocks for varying arg counts (msg + 0, 2, or 4 keyval args). + for _, method := range []string{"Info", "Debug", "Warn", "Error"} { + logger.On(method, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + logger.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + } + + return &HTTPServerModule{ + config: &HTTPServerConfig{ + Host: "127.0.0.1", + Port: 9999, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + ShutdownTimeout: 5 * time.Second, + }, + logger: logger, + } +} + +// --------------------------------------------------------------------------- +// Drainable +// --------------------------------------------------------------------------- + +func TestHTTPServerModule_Drainable(t *testing.T) { + // Verify interface compliance at compile time. + var _ modular.Drainable = (*HTTPServerModule)(nil) + + m := newTestModule(t) + + // Before PreStop, draining should be false. + assert.False(t, m.draining, "draining flag should be false before PreStop") + + err := m.PreStop(context.Background()) + require.NoError(t, err) + + // After PreStop, draining should be true. + assert.True(t, m.draining, "draining flag should be true after PreStop") +} + +// --------------------------------------------------------------------------- +// Reloadable +// --------------------------------------------------------------------------- + +func TestHTTPServerModule_Reloadable(t *testing.T) { + var _ modular.Reloadable = (*HTTPServerModule)(nil) + + t.Run("CanReload false when not started", func(t *testing.T) { + m := newTestModule(t) + assert.False(t, m.CanReload()) + }) + + t.Run("CanReload true when started", func(t *testing.T) { + m := newTestModule(t) + m.started = true + assert.True(t, m.CanReload()) + }) + + t.Run("ReloadTimeout is 5 seconds", func(t *testing.T) { + m := newTestModule(t) + assert.Equal(t, 5*time.Second, m.ReloadTimeout()) + }) + + t.Run("Reload updates config timeouts", func(t *testing.T) { + m := newTestModule(t) + m.started = true + m.server = &http.Server{ + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + changes := []modular.ConfigChange{ + {FieldPath: "ReadTimeout", NewValue: "30s"}, + {FieldPath: "WriteTimeout", NewValue: "25s"}, + {FieldPath: "httpserver.IdleTimeout", NewValue: "120s"}, + } + + err := m.Reload(context.Background(), changes) + require.NoError(t, err) + + // Config is updated; server fields are not mutated to avoid data races + // on a running http.Server (new values take effect on restart). + assert.Equal(t, 30*time.Second, m.config.ReadTimeout) + assert.Equal(t, 25*time.Second, m.config.WriteTimeout) + assert.Equal(t, 120*time.Second, m.config.IdleTimeout) + }) + + t.Run("Reload rejects invalid duration", func(t *testing.T) { + m := newTestModule(t) + m.started = true + m.server = &http.Server{} + + changes := []modular.ConfigChange{ + {FieldPath: "ReadTimeout", NewValue: "not-a-duration"}, + } + + err := m.Reload(context.Background(), changes) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid ReadTimeout") + }) + + t.Run("Reload fails when server not started", func(t *testing.T) { + m := newTestModule(t) + + err := m.Reload(context.Background(), []modular.ConfigChange{ + {FieldPath: "ReadTimeout", NewValue: "10s"}, + }) + assert.ErrorIs(t, err, ErrServerNotStarted) + }) + + t.Run("Reload ignores unknown fields", func(t *testing.T) { + m := newTestModule(t) + m.started = true + m.server = &http.Server{} + + changes := []modular.ConfigChange{ + {FieldPath: "UnknownField", NewValue: "whatever"}, + } + + err := m.Reload(context.Background(), changes) + require.NoError(t, err) + }) +} + +// --------------------------------------------------------------------------- +// MetricsProvider +// --------------------------------------------------------------------------- + +func TestHTTPServerModule_MetricsProvider(t *testing.T) { + var _ modular.MetricsProvider = (*HTTPServerModule)(nil) + + t.Run("metrics when not started", func(t *testing.T) { + m := newTestModule(t) + + metrics := m.CollectMetrics(context.Background()) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 0.0, metrics.Values["started"]) + assert.Equal(t, float64(9999), metrics.Values["port"]) + }) + + t.Run("metrics when started", func(t *testing.T) { + m := newTestModule(t) + m.started = true + + metrics := m.CollectMetrics(context.Background()) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 1.0, metrics.Values["started"]) + assert.Equal(t, float64(9999), metrics.Values["port"]) + }) +} diff --git a/modules/jsonschema/README.md b/modules/jsonschema/README.md index b05a1acf..db683786 100644 --- a/modules/jsonschema/README.md +++ b/modules/jsonschema/README.md @@ -1,9 +1,9 @@ # JSON Schema Module for Modular -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/jsonschema) -[![Modules CI](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/CrisisTextLine/modular/actions/workflows/modules-ci.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/jsonschema.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/jsonschema) +[![Modules CI](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml/badge.svg)](https://github.com/GoCodeAlone/modular/actions/workflows/modules-ci.yml) -A [Modular](https://github.com/CrisisTextLine/modular) module that provides JSON Schema validation capabilities. +A [Modular](https://github.com/GoCodeAlone/modular) module that provides JSON Schema validation capabilities. ## Overview @@ -21,7 +21,7 @@ The JSON Schema module provides a service for validating JSON data against JSON ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/jsonschema@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/jsonschema@v1.0.0 ``` ## Usage @@ -30,8 +30,8 @@ go get github.com/CrisisTextLine/modular/modules/jsonschema@v1.0.0 ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/jsonschema" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/jsonschema" ) func main() { diff --git a/modules/jsonschema/bdd_event_handling_test.go b/modules/jsonschema/bdd_event_handling_test.go index 0c80ee47..a0142b77 100644 --- a/modules/jsonschema/bdd_event_handling_test.go +++ b/modules/jsonschema/bdd_event_handling_test.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // CloudEvents emission and handling step methods diff --git a/modules/jsonschema/bdd_module_initialization_test.go b/modules/jsonschema/bdd_module_initialization_test.go index 54e9c8c4..b4951b21 100644 --- a/modules/jsonschema/bdd_module_initialization_test.go +++ b/modules/jsonschema/bdd_module_initialization_test.go @@ -3,7 +3,7 @@ package jsonschema import ( "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Core module initialization and setup step methods diff --git a/modules/jsonschema/go.mod b/modules/jsonschema/go.mod index 6c09529e..ca1bbe96 100644 --- a/modules/jsonschema/go.mod +++ b/modules/jsonschema/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/jsonschema +module github.com/GoCodeAlone/modular/modules/jsonschema -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 diff --git a/modules/jsonschema/go.sum b/modules/jsonschema/go.sum index c2317154..461ddc62 100644 --- a/modules/jsonschema/go.sum +++ b/modules/jsonschema/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/jsonschema/jsonschema_module_bdd_test.go b/modules/jsonschema/jsonschema_module_bdd_test.go index 53e45e90..707699a6 100644 --- a/modules/jsonschema/jsonschema_module_bdd_test.go +++ b/modules/jsonschema/jsonschema_module_bdd_test.go @@ -5,7 +5,7 @@ import ( "sync" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/jsonschema/module.go b/modules/jsonschema/module.go index bfdacd6f..5b9e92f5 100644 --- a/modules/jsonschema/module.go +++ b/modules/jsonschema/module.go @@ -146,7 +146,7 @@ import ( "context" "fmt" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/jsonschema/schema_test.go b/modules/jsonschema/schema_test.go index 0b88f6e4..2b947cd6 100644 --- a/modules/jsonschema/schema_test.go +++ b/modules/jsonschema/schema_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/jsonschema" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/jsonschema" ) // Define static error diff --git a/modules/jsonschema/service.go b/modules/jsonschema/service.go index aacbfd89..674a2aeb 100644 --- a/modules/jsonschema/service.go +++ b/modules/jsonschema/service.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/santhosh-tekuri/jsonschema/v6" ) diff --git a/modules/letsencrypt/README.md b/modules/letsencrypt/README.md index a1198300..b2a7aae0 100644 --- a/modules/letsencrypt/README.md +++ b/modules/letsencrypt/README.md @@ -2,7 +2,7 @@ The Let's Encrypt module provides automatic SSL/TLS certificate generation and management using Let's Encrypt's ACME protocol. It integrates seamlessly with the Modular framework to provide HTTPS capabilities for your applications. -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/letsencrypt) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/letsencrypt.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/letsencrypt) ## Features @@ -17,7 +17,7 @@ The Let's Encrypt module provides automatic SSL/TLS certificate generation and m ## Installation ```bash -go get github.com/CrisisTextLine/modular/modules/letsencrypt +go get github.com/GoCodeAlone/modular/modules/letsencrypt ``` ## Quick Start @@ -32,9 +32,9 @@ import ( "log/slog" "os" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/letsencrypt" - "github.com/CrisisTextLine/modular/modules/httpserver" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/letsencrypt" + "github.com/GoCodeAlone/modular/modules/httpserver" ) type AppConfig struct { diff --git a/modules/letsencrypt/bdd_core_functionality_test.go b/modules/letsencrypt/bdd_core_functionality_test.go index be6f6fde..5f688933 100644 --- a/modules/letsencrypt/bdd_core_functionality_test.go +++ b/modules/letsencrypt/bdd_core_functionality_test.go @@ -8,7 +8,7 @@ import ( "strings" "sync" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/cucumber/godog" ) diff --git a/modules/letsencrypt/bdd_event_system_test.go b/modules/letsencrypt/bdd_event_system_test.go index 660e5036..19d31ae1 100644 --- a/modules/letsencrypt/bdd_event_system_test.go +++ b/modules/letsencrypt/bdd_event_system_test.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/letsencrypt/go.mod b/modules/letsencrypt/go.mod index cf200d76..8ef58f3a 100644 --- a/modules/letsencrypt/go.mod +++ b/modules/letsencrypt/go.mod @@ -1,10 +1,10 @@ -module github.com/CrisisTextLine/modular/modules/letsencrypt +module github.com/GoCodeAlone/modular/modules/letsencrypt -go 1.25 +go 1.26 require ( - github.com/CrisisTextLine/modular v1.11.11 - github.com/CrisisTextLine/modular/modules/httpserver v0.2.3 + github.com/GoCodeAlone/modular v1.12.2 + github.com/GoCodeAlone/modular/modules/httpserver v1.12.0 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-acme/lego/v4 v4.26.0 diff --git a/modules/letsencrypt/go.sum b/modules/letsencrypt/go.sum index 4c53f022..70832753 100644 --- a/modules/letsencrypt/go.sum +++ b/modules/letsencrypt/go.sum @@ -29,10 +29,10 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= -github.com/CrisisTextLine/modular/modules/httpserver v0.2.3 h1:SKAySbzMHnsNzggg3ntx+/aOqv+kRJME3zZzgKW4t18= -github.com/CrisisTextLine/modular/modules/httpserver v0.2.3/go.mod h1:lIVyUIIMyTYZI2sprVkmREh+8z7vbENTKCHKNlRou3I= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= +github.com/GoCodeAlone/modular/modules/httpserver v1.12.0 h1:nVaeiC59OEqMj0jcDZwIUHrba4CdPT9ntcGBAw81iKs= +github.com/GoCodeAlone/modular/modules/httpserver v1.12.0/go.mod h1:sVklMEsxKxKihMDz5Zh2RFqnwpgXd/IT9lbAVGlkWEE= github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4= github.com/aws/aws-sdk-go-v2 v1.39.0/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.31.8 h1:kQjtOLlTU4m4A64TsRcqwNChhGCwaPBt+zCQt/oWsHU= diff --git a/modules/letsencrypt/module.go b/modules/letsencrypt/module.go index 04085300..1a2004f8 100644 --- a/modules/letsencrypt/module.go +++ b/modules/letsencrypt/module.go @@ -147,7 +147,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/route53" "github.com/go-acme/lego/v4/registration" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/letsencrypt/module_test.go b/modules/letsencrypt/module_test.go index 9adaa950..63180cf6 100644 --- a/modules/letsencrypt/module_test.go +++ b/modules/letsencrypt/module_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular/modules/httpserver" + "github.com/GoCodeAlone/modular/modules/httpserver" "github.com/go-acme/lego/v4/certificate" ) diff --git a/modules/logmasker/README.md b/modules/logmasker/README.md index 5d76c0a1..af59d3cb 100644 --- a/modules/logmasker/README.md +++ b/modules/logmasker/README.md @@ -1,6 +1,6 @@ # LogMasker Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/logmasker) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/logmasker.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/logmasker) The LogMasker Module provides centralized log masking functionality for Modular applications. It acts as a decorator around the standard Logger interface to automatically redact sensitive information from log output based on configurable rules. @@ -20,7 +20,7 @@ The LogMasker Module provides centralized log masking functionality for Modular Add the logmasker module to your project: ```bash -go get github.com/CrisisTextLine/modular/modules/logmasker +go get github.com/GoCodeAlone/modular/modules/logmasker ``` ## Configuration @@ -72,8 +72,8 @@ Register the module and use the masking logger service: package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/logmasker" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/logmasker" ) func main() { diff --git a/modules/logmasker/go.mod b/modules/logmasker/go.mod index 510c50ea..cc6de898 100644 --- a/modules/logmasker/go.mod +++ b/modules/logmasker/go.mod @@ -1,8 +1,8 @@ -module github.com/CrisisTextLine/modular/modules/logmasker +module github.com/GoCodeAlone/modular/modules/logmasker -go 1.25 +go 1.26 -require github.com/CrisisTextLine/modular v1.11.11 +require github.com/GoCodeAlone/modular v1.12.2 require ( github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/modules/logmasker/go.sum b/modules/logmasker/go.sum index 927a069c..97a3f5a2 100644 --- a/modules/logmasker/go.sum +++ b/modules/logmasker/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/modules/logmasker/module.go b/modules/logmasker/module.go index 9beb7375..4ad1084d 100644 --- a/modules/logmasker/module.go +++ b/modules/logmasker/module.go @@ -68,7 +68,7 @@ import ( "regexp" "strings" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // ErrInvalidConfigType indicates the configuration type is incorrect for this module. diff --git a/modules/logmasker/module_test.go b/modules/logmasker/module_test.go index 5d66c6f3..474baa10 100644 --- a/modules/logmasker/module_test.go +++ b/modules/logmasker/module_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // MockLogger implements modular.Logger for testing. diff --git a/modules/reverseproxy/DOCUMENTATION.md b/modules/reverseproxy/DOCUMENTATION.md index c666c5b1..845e86d0 100644 --- a/modules/reverseproxy/DOCUMENTATION.md +++ b/modules/reverseproxy/DOCUMENTATION.md @@ -70,7 +70,7 @@ ## Introduction -The Reverse Proxy module is a powerful and flexible API gateway component that routes HTTP requests to multiple backend services and provides advanced features for response aggregation, custom transformations, and tenant-aware routing. It's built for the [Modular](https://github.com/CrisisTextLine/modular) framework and designed to be easily configurable while supporting complex routing scenarios. +The Reverse Proxy module is a powerful and flexible API gateway component that routes HTTP requests to multiple backend services and provides advanced features for response aggregation, custom transformations, and tenant-aware routing. It's built for the [Modular](https://github.com/GoCodeAlone/modular) framework and designed to be easily configurable while supporting complex routing scenarios. ### Key Features @@ -125,7 +125,7 @@ The module works by: To use the Reverse Proxy module in your Go application: ```go -go get github.com/CrisisTextLine/modular/modules/reverseproxy@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/reverseproxy@v1.0.0 ``` ## Configuration diff --git a/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md b/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md index 7b6053bd..3a19b4ee 100644 --- a/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md +++ b/modules/reverseproxy/FEATURE_FLAG_MIGRATION_GUIDE.md @@ -91,7 +91,7 @@ func (e *MyCustomEvaluator) EvaluateFlag(ctx context.Context, flagID string, ten The new system supports special sentinel errors for better control: ```go -import "github.com/CrisisTextLine/modular/modules/reverseproxy" +import "github.com/GoCodeAlone/modular/modules/reverseproxy" func (e *MyCustomEvaluator) EvaluateFlag(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request) (bool, error) { // Check if you can make a decision diff --git a/modules/reverseproxy/README.md b/modules/reverseproxy/README.md index af8858e6..f41e44dc 100644 --- a/modules/reverseproxy/README.md +++ b/modules/reverseproxy/README.md @@ -1,8 +1,8 @@ # Reverse Proxy Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/reverseproxy) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/reverseproxy.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/reverseproxy) -A module for the [Modular](https://github.com/CrisisTextLine/modular) framework that provides a flexible reverse proxy with advanced routing capabilities. +A module for the [Modular](https://github.com/GoCodeAlone/modular) framework that provides a flexible reverse proxy with advanced routing capabilities. ## Overview @@ -24,6 +24,9 @@ The Reverse Proxy module functions as a versatile API gateway that can route req * **Tenant Awareness**: Support for multi-tenant environments with tenant-specific routing * **Pattern-Based Routing**: Direct requests to specific backends based on URL patterns * **Custom Endpoint Mapping**: Define flexible mappings from frontend endpoints to backend services +* **Pipeline Strategy**: Chain backend requests where each stage's response informs the next (map/reduce) +* **Fan-Out-Merge Strategy**: Parallel backend requests with custom ID-based response merging +* **Empty Response Policies**: Configurable handling of empty backend responses (allow, skip, or fail) * **Health Checking**: Continuous monitoring of backend service availability with DNS resolution and HTTP checks * **Circuit Breaker**: Automatic failure detection and recovery with configurable thresholds * **Response Caching**: Performance optimization with TTL-based caching @@ -33,7 +36,7 @@ The Reverse Proxy module functions as a versatile API gateway that can route req ## Installation ```go -go get github.com/CrisisTextLine/modular/modules/reverseproxy@v1.0.0 +go get github.com/GoCodeAlone/modular/modules/reverseproxy@v1.0.0 ``` ## Documentation @@ -48,9 +51,9 @@ go get github.com/CrisisTextLine/modular/modules/reverseproxy@v1.0.0 package main import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/chimux" - "github.com/CrisisTextLine/modular/modules/reverseproxy" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/chimux" + "github.com/GoCodeAlone/modular/modules/reverseproxy" "log/slog" "os" ) @@ -366,6 +369,139 @@ The module supports several advanced features: 11. **Connection Pooling**: Advanced connection pool management with configurable limits 12. **Queue Management**: Request queueing with configurable sizes and timeouts 13. **Error Handling**: Comprehensive error handling with custom pages and retry logic +14. **Pipeline Strategy**: Chain backend requests where each stage's response informs the next request (map/reduce pattern) +15. **Fan-Out-Merge Strategy**: Parallel backend requests with custom ID-based response merging +16. **Empty Response Policies**: Configurable handling of empty backend responses (allow, skip, or fail) + +### Composite Route Strategies + +Composite routes allow combining responses from multiple backend services. The module supports five strategies: + +#### first-success +Tries backends sequentially until one succeeds. Use case: High-availability setup with primary and fallback backends. + +```yaml +composite_routes: + "/api/data": + pattern: "/api/data" + backends: ["primary-backend", "fallback-backend"] + strategy: "first-success" +``` + +#### merge +Executes all backend requests in parallel and merges JSON responses by backend ID. + +```yaml +composite_routes: + "/api/user/profile": + pattern: "/api/user/profile" + backends: ["user-backend", "analytics-backend"] + strategy: "merge" +``` + +#### sequential +Executes requests one at a time, returning the last successful response. + +```yaml +composite_routes: + "/api/process": + pattern: "/api/process" + backends: ["auth-backend", "processing-backend"] + strategy: "sequential" +``` + +#### pipeline +Executes backends sequentially where each stage's response can inform the next stage's request. Requires programmatic configuration via `SetPipelineConfig()`. + +Use case: A list page shows queued conversations. Backend A returns conversation details, those IDs are fed into Backend B to fetch follow-up information, and the responses are merged. + +```yaml +composite_routes: + "/api/conversations": + pattern: "/api/conversations" + backends: ["conversations-backend", "followup-backend"] + strategy: "pipeline" + empty_policy: "skip-empty" # Optional: allow-empty, skip-empty, fail-on-empty +``` + +```go +proxyModule.SetPipelineConfig("/api/conversations", reverseproxy.PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, + previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + // Extract IDs from previous response and build next request + var convResp struct { + Conversations []struct{ ID string `json:"id"` } `json:"conversations"` + } + json.Unmarshal(previousResponses["conversations-backend"], &convResp) + + ids := []string{} + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + url := "http://followup-service/followups?ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(ctx, "GET", url, nil) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, + allResponses map[string][]byte) (*http.Response, error) { + // Merge follow-up data into conversations + // ... custom merging logic ... + return reverseproxy.MakeJSONResponse(http.StatusOK, mergedResult) + }, +}) +``` + +#### fan-out-merge +Executes all backends in parallel (like merge), then applies a custom merger function for ID-based matching, filtering, or complex data correlation. Requires programmatic configuration via `SetFanOutMerger()`. + +Use case: A ticket dashboard where tickets come from one service and priority/assignment data comes from another. The merger matches by ticket ID. + +```yaml +composite_routes: + "/api/tickets": + pattern: "/api/tickets" + backends: ["tickets-backend", "assignments-backend"] + strategy: "fan-out-merge" + empty_policy: "allow-empty" # Optional +``` + +```go +proxyModule.SetFanOutMerger("/api/tickets", func(ctx context.Context, + originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Parse both responses + var ticketsResp struct { Tickets []map[string]interface{} `json:"tickets"` } + json.Unmarshal(responses["tickets-backend"], &ticketsResp) + + var assignResp struct { Assignments map[string]interface{} `json:"assignments"` } + json.Unmarshal(responses["assignments-backend"], &assignResp) + + // Merge by ID + for i, ticket := range ticketsResp.Tickets { + if id, ok := ticket["id"].(string); ok { + if assignment, exists := assignResp.Assignments[id]; exists { + ticketsResp.Tickets[i]["assignment"] = assignment + } + } + } + return reverseproxy.MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "tickets": ticketsResp.Tickets, + }) +}) +``` + +#### Empty Response Policies + +For `pipeline` and `fan-out-merge` strategies, you can control how empty backend responses are handled: + +| Policy | Description | +|--------|-------------| +| `allow-empty` | Include empty responses in the result set (default) | +| `skip-empty` | Silently drop empty responses from the result | +| `fail-on-empty` | Fail the entire request if any backend returns empty | + +Set via config (`empty_policy` field) or programmatically: +```go +proxyModule.SetEmptyResponsePolicy("/api/route", reverseproxy.EmptyResponseSkip) +``` ### Debug Endpoints diff --git a/modules/reverseproxy/backend_test.go b/modules/reverseproxy/backend_test.go index 162ed00f..1f05c8d4 100644 --- a/modules/reverseproxy/backend_test.go +++ b/modules/reverseproxy/backend_test.go @@ -9,7 +9,7 @@ import ( "net/url" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/bdd_caching_tenant_test.go b/modules/reverseproxy/bdd_caching_tenant_test.go index 5542924d..760891ab 100644 --- a/modules/reverseproxy/bdd_caching_tenant_test.go +++ b/modules/reverseproxy/bdd_caching_tenant_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Caching Scenarios diff --git a/modules/reverseproxy/bdd_circuit_error_scenarios_test.go b/modules/reverseproxy/bdd_circuit_error_scenarios_test.go index bf9a1be7..743aaf14 100644 --- a/modules/reverseproxy/bdd_circuit_error_scenarios_test.go +++ b/modules/reverseproxy/bdd_circuit_error_scenarios_test.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Circuit Breaker Response Scenarios diff --git a/modules/reverseproxy/bdd_composite_pipeline_test.go b/modules/reverseproxy/bdd_composite_pipeline_test.go new file mode 100644 index 00000000..0334cd6f --- /dev/null +++ b/modules/reverseproxy/bdd_composite_pipeline_test.go @@ -0,0 +1,720 @@ +package reverseproxy + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" +) + +// ============================================================================ +// Pipeline Strategy BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAPipelineCompositeRouteWithTwoBackends() error { + ctx.resetContext() + + // Backend 1: returns a list of items with IDs + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{ + {"id": "item-1", "name": "First Item"}, + {"id": "item-2", "name": "Second Item"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backend1) + + // Backend 2: returns details for given IDs + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idsParam := r.URL.Query().Get("ids") + details := make(map[string]interface{}) + if idsParam != "" { + for _, id := range strings.Split(idsParam, ",") { + if id == "item-1" { + details[id] = map[string]interface{}{"category": "A", "priority": "high"} + } + if id == "item-2" { + details[id] = map[string]interface{}{"category": "B", "priority": "low"} + } + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "details": details, + }) + })) + ctx.testServers = append(ctx.testServers, backend2) + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "items-backend", + BackendServices: map[string]string{ + "items-backend": backend1.URL, + "details-backend": backend2.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/pipeline": { + Pattern: "/api/pipeline", + Backends: []string{"items-backend", "details-backend"}, + Strategy: "pipeline", + }, + }, + HealthCheck: HealthCheckConfig{ + Enabled: false, + Interval: 30 * time.Second, + }, + CircuitBreakerConfig: CircuitBreakerConfig{ + Enabled: false, + }, + } + + // Capture backend2 URL for use in the closure + backend2URL := backend2.URL + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set pipeline config on the module AFTER setup creates it + ctx.module.SetPipelineConfig("/api/pipeline", PipelineConfig{ + RequestBuilder: func(rctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "details-backend" { + var itemsResp struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + if body, ok := previousResponses["items-backend"]; ok { + if err := json.Unmarshal(body, &itemsResp); err != nil { + return nil, err + } + } + ids := make([]string, 0, len(itemsResp.Items)) + for _, item := range itemsResp.Items { + ids = append(ids, item.ID) + } + url := backend2URL + "/details?ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(rctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(rctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + var itemsResp struct { + Items []map[string]interface{} `json:"items"` + } + json.Unmarshal(allResponses["items-backend"], &itemsResp) + + var detailsResp struct { + Details map[string]interface{} `json:"details"` + } + json.Unmarshal(allResponses["details-backend"], &detailsResp) + + for i, item := range itemsResp.Items { + if id, ok := item["id"].(string); ok { + if detail, exists := detailsResp.Details[id]; exists { + itemsResp.Items[i]["detail"] = detail + } + } + } + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "items": itemsResp.Items, + }) + }, + }) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestToThePipelineRoute() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + backends := []*Backend{ + {ID: "items-backend", URL: ctx.module.config.BackendServices["items-backend"], Client: http.DefaultClient}, + {ID: "details-backend", URL: ctx.module.config.BackendServices["details-backend"], Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + + if pipelineCfg, ok := ctx.module.pipelineConfigs["/api/pipeline"]; ok { + handler.SetPipelineConfig(pipelineCfg) + } + + req := httptest.NewRequest("GET", "/api/pipeline", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + resp.Body = io.NopCloser(strings.NewReader(string(body))) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theFirstBackendShouldBeCalledWithTheOriginalRequest() error { + if ctx.lastResponse == nil { + return fmt.Errorf("no response received") + } + if ctx.lastResponse.StatusCode != http.StatusOK { + return fmt.Errorf("expected status 200, got %d", ctx.lastResponse.StatusCode) + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theSecondBackendShouldReceiveDataDerivedFromTheFirstResponse() error { + if ctx.lastResponseBody == nil { + return fmt.Errorf("no response body") + } + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + items, ok := result["items"].([]interface{}) + if !ok { + return fmt.Errorf("expected items array in response") + } + if len(items) == 0 { + return fmt.Errorf("expected at least one item") + } + // Check that items have detail data (proving second backend was called with IDs from first) + item1 := items[0].(map[string]interface{}) + if _, hasDetail := item1["detail"]; !hasDetail { + return fmt.Errorf("item should have detail from second backend") + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theFinalResponseShouldContainMergedDataFromAllStages() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + items := result["items"].([]interface{}) + if len(items) != 2 { + return fmt.Errorf("expected 2 items, got %d", len(items)) + } + + // Verify item-1 has detail with category A + item1 := items[0].(map[string]interface{}) + detail1 := item1["detail"].(map[string]interface{}) + if detail1["category"] != "A" { + return fmt.Errorf("item-1 should have category A") + } + return nil +} + +// ============================================================================ +// Fan-Out-Merge Strategy BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAFanOutMergeCompositeRouteWithTwoBackends() error { + ctx.resetContext() + + // Backend A: returns items + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "records": []map[string]interface{}{ + {"id": "r1", "title": "Record One"}, + {"id": "r2", "title": "Record Two"}, + {"id": "r3", "title": "Record Three"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backendA) + + // Backend B: returns tags keyed by ID + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "tags": map[string]interface{}{ + "r1": []string{"urgent", "new"}, + "r3": []string{"follow-up"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backendB) + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "records-backend", + BackendServices: map[string]string{ + "records-backend": backendA.URL, + "tags-backend": backendB.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/fanout": { + Pattern: "/api/fanout", + Backends: []string{"records-backend", "tags-backend"}, + Strategy: "fan-out-merge", + }, + }, + HealthCheck: HealthCheckConfig{ + Enabled: false, + Interval: 30 * time.Second, + }, + CircuitBreakerConfig: CircuitBreakerConfig{ + Enabled: false, + }, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set fan-out merger AFTER setup creates the module + ctx.module.SetFanOutMerger("/api/fanout", func(rctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var recordsResp struct { + Records []map[string]interface{} `json:"records"` + } + json.Unmarshal(responses["records-backend"], &recordsResp) + + var tagsResp struct { + Tags map[string]interface{} `json:"tags"` + } + json.Unmarshal(responses["tags-backend"], &tagsResp) + + for i, record := range recordsResp.Records { + if id, ok := record["id"].(string); ok { + if tags, exists := tagsResp.Tags[id]; exists { + recordsResp.Records[i]["tags"] = tags + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "records": recordsResp.Records, + }) + }) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestToTheFanOutMergeRoute() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + backends := []*Backend{ + {ID: "records-backend", URL: ctx.module.config.BackendServices["records-backend"], Client: http.DefaultClient}, + {ID: "tags-backend", URL: ctx.module.config.BackendServices["tags-backend"], Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + + if merger, ok := ctx.module.fanOutMergers["/api/fanout"]; ok { + handler.SetFanOutMerger(merger) + } + + req := httptest.NewRequest("GET", "/api/fanout", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + resp.Body = io.NopCloser(strings.NewReader(string(body))) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) bothBackendsShouldBeCalledInParallel() error { + if ctx.lastResponse == nil { + return fmt.Errorf("no response received") + } + if ctx.lastResponse.StatusCode != http.StatusOK { + return fmt.Errorf("expected status 200, got %d", ctx.lastResponse.StatusCode) + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theResponsesShouldBeMergedByMatchingIDs() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + records, ok := result["records"].([]interface{}) + if !ok { + return fmt.Errorf("expected records array") + } + if len(records) != 3 { + return fmt.Errorf("expected 3 records, got %d", len(records)) + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) itemsWithMatchingAncillaryDataShouldBeEnriched() error { + var result map[string]interface{} + json.Unmarshal(ctx.lastResponseBody, &result) + + records := result["records"].([]interface{}) + + // r1 should have tags + r1 := records[0].(map[string]interface{}) + if _, hasTags := r1["tags"]; !hasTags { + return fmt.Errorf("r1 should have tags") + } + + // r2 should NOT have tags + r2 := records[1].(map[string]interface{}) + if _, hasTags := r2["tags"]; hasTags { + return fmt.Errorf("r2 should NOT have tags") + } + + // r3 should have tags + r3 := records[2].(map[string]interface{}) + if _, hasTags := r3["tags"]; !hasTags { + return fmt.Errorf("r3 should have tags") + } + + return nil +} + +// ============================================================================ +// Empty Response Policy BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAPipelineRouteWithSkipEmptyPolicy() error { + ctx.resetContext() + + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage1","value":42}`)) + })) + ctx.testServers = append(ctx.testServers, backend1) + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty response + })) + ctx.testServers = append(ctx.testServers, backend2) + + // Capture URL for closure + backend2URL := backend2.URL + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "data-backend", + BackendServices: map[string]string{ + "data-backend": backend1.URL, + "empty-backend": backend2.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/skip-empty": { + Pattern: "/api/skip-empty", + Backends: []string{"data-backend", "empty-backend"}, + Strategy: "pipeline", + EmptyPolicy: "skip-empty", + }, + }, + HealthCheck: HealthCheckConfig{Enabled: false, Interval: 30 * time.Second}, + CircuitBreakerConfig: CircuitBreakerConfig{Enabled: false}, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set pipeline config and empty policy AFTER setup + ctx.module.SetPipelineConfig("/api/skip-empty", PipelineConfig{ + RequestBuilder: func(rctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(rctx, "GET", backend2URL+"/test", nil) + }, + }) + ctx.module.SetEmptyResponsePolicy("/api/skip-empty", EmptyResponseSkip) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestAndABackendReturnsAnEmptyResponse() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + var strategy CompositeStrategy + var pattern string + for p, route := range ctx.module.config.CompositeRoutes { + strategy = CompositeStrategy(route.Strategy) + pattern = p + break + } + + var backends []*Backend + for _, name := range ctx.module.config.CompositeRoutes[pattern].Backends { + backends = append(backends, &Backend{ + ID: name, + URL: ctx.module.config.BackendServices[name], + Client: http.DefaultClient, + }) + } + + handler := NewCompositeHandler(backends, strategy, 10*time.Second) + + emptyPolicy := EmptyResponsePolicy(ctx.module.config.CompositeRoutes[pattern].EmptyPolicy) + if policy, ok := ctx.module.emptyResponsePolicies[pattern]; ok { + emptyPolicy = policy + } + handler.SetEmptyResponsePolicy(emptyPolicy) + + if pipelineCfg, ok := ctx.module.pipelineConfigs[pattern]; ok { + handler.SetPipelineConfig(pipelineCfg) + } + if merger, ok := ctx.module.fanOutMergers[pattern]; ok { + handler.SetFanOutMerger(merger) + } + + req := httptest.NewRequest("GET", pattern, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theEmptyResponseShouldBeExcludedFromTheResult() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // "empty-backend" should not be in the response + if _, found := result["empty-backend"]; found { + return fmt.Errorf("empty backend response should be excluded") + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theNonEmptyResponsesShouldStillBePresent() error { + var result map[string]interface{} + json.Unmarshal(ctx.lastResponseBody, &result) + + if _, found := result["data-backend"]; !found { + return fmt.Errorf("data-backend response should be present") + } + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iHaveAFanOutMergeRouteWithFailOnEmptyPolicy() error { + ctx.resetContext() + + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"ok"}`)) + })) + ctx.testServers = append(ctx.testServers, backendA) + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(``)) // Completely empty + })) + ctx.testServers = append(ctx.testServers, backendB) + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "ok-backend", + BackendServices: map[string]string{ + "ok-backend": backendA.URL, + "empty-backend": backendB.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/fail-empty": { + Pattern: "/api/fail-empty", + Backends: []string{"ok-backend", "empty-backend"}, + Strategy: "fan-out-merge", + EmptyPolicy: "fail-on-empty", + }, + }, + HealthCheck: HealthCheckConfig{Enabled: false, Interval: 30 * time.Second}, + CircuitBreakerConfig: CircuitBreakerConfig{Enabled: false}, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set merger and policy AFTER setup + ctx.module.SetFanOutMerger("/api/fail-empty", func(rctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return MakeJSONResponse(http.StatusOK, responses) + }) + ctx.module.SetEmptyResponsePolicy("/api/fail-empty", EmptyResponseFail) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) theRequestShouldFailWithABadGatewayError() error { + if ctx.lastResponse == nil { + return fmt.Errorf("no response received") + } + if ctx.lastResponse.StatusCode != http.StatusBadGateway { + return fmt.Errorf("expected status 502, got %d", ctx.lastResponse.StatusCode) + } + return nil +} + +// ============================================================================ +// Pipeline Filter BDD Steps +// ============================================================================ + +func (ctx *ReverseProxyBDDTestContext) iHaveAPipelineRouteThatFiltersByAncillaryBackendData() error { + ctx.resetContext() + + // Backend A: returns all conversations + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "c1", "title": "Conv 1", "status": "queued"}, + {"id": "c2", "title": "Conv 2", "status": "queued"}, + {"id": "c3", "title": "Conv 3", "status": "active"}, + {"id": "c4", "title": "Conv 4", "status": "queued"}, + }, + }) + })) + ctx.testServers = append(ctx.testServers, backendA) + + // Backend B: returns which conversations are flagged + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "flagged_ids": []string{"c1", "c4"}, + }) + })) + ctx.testServers = append(ctx.testServers, backendB) + + // Capture URL for closure + backendBURL := backendB.URL + + ctx.config = &ReverseProxyConfig{ + DefaultBackend: "conv-backend", + BackendServices: map[string]string{ + "conv-backend": backendA.URL, + "flags-backend": backendB.URL, + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/filter": { + Pattern: "/api/filter", + Backends: []string{"conv-backend", "flags-backend"}, + Strategy: "pipeline", + }, + }, + HealthCheck: HealthCheckConfig{Enabled: false, Interval: 30 * time.Second}, + CircuitBreakerConfig: CircuitBreakerConfig{Enabled: false}, + } + + if err := ctx.setupApplicationWithConfig(); err != nil { + return err + } + + // Set pipeline config AFTER setup + ctx.module.SetPipelineConfig("/api/filter", PipelineConfig{ + RequestBuilder: func(rctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(rctx, "GET", backendBURL+"/flags", nil) + }, + ResponseMerger: func(rctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + json.Unmarshal(allResponses["conv-backend"], &convResp) + + var flagsResp struct { + FlaggedIDs []string `json:"flagged_ids"` + } + json.Unmarshal(allResponses["flags-backend"], &flagsResp) + + flagSet := make(map[string]bool) + for _, id := range flagsResp.FlaggedIDs { + flagSet[id] = true + } + + var filtered []map[string]interface{} + for _, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok && flagSet[id] { + conv["flagged"] = true + filtered = append(filtered, conv) + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "filtered_conversations": filtered, + "total_filtered": len(filtered), + }) + }, + }) + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) iSendARequestToFetchFilteredResults() error { + if ctx.module == nil { + return fmt.Errorf("module not initialized") + } + + backends := []*Backend{ + {ID: "conv-backend", URL: ctx.module.config.BackendServices["conv-backend"], Client: http.DefaultClient}, + {ID: "flags-backend", URL: ctx.module.config.BackendServices["flags-backend"], Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + if pipelineCfg, ok := ctx.module.pipelineConfigs["/api/filter"]; ok { + handler.SetPipelineConfig(pipelineCfg) + } + + req := httptest.NewRequest("GET", "/api/filter", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + ctx.lastResponse = resp + body, _ := io.ReadAll(resp.Body) + ctx.lastResponseBody = body + + return nil +} + +func (ctx *ReverseProxyBDDTestContext) onlyItemsMatchingTheAncillaryCriteriaShouldBeReturned() error { + var result map[string]interface{} + if err := json.Unmarshal(ctx.lastResponseBody, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + filtered := result["filtered_conversations"].([]interface{}) + totalFiltered := result["total_filtered"].(float64) + + if int(totalFiltered) != 2 { + return fmt.Errorf("expected 2 filtered conversations, got %v", totalFiltered) + } + if len(filtered) != 2 { + return fmt.Errorf("expected 2 filtered conversations in array, got %d", len(filtered)) + } + + // Verify only c1 and c4 are present + ids := make(map[string]bool) + for _, item := range filtered { + m := item.(map[string]interface{}) + ids[m["id"].(string)] = true + if m["flagged"] != true { + return fmt.Errorf("expected flagged=true on filtered item") + } + } + + if !ids["c1"] || !ids["c4"] { + return fmt.Errorf("expected c1 and c4 in filtered results, got %v", ids) + } + + return nil +} diff --git a/modules/reverseproxy/bdd_core_module_test.go b/modules/reverseproxy/bdd_core_module_test.go index e3ca6f01..43f640d9 100644 --- a/modules/reverseproxy/bdd_core_module_test.go +++ b/modules/reverseproxy/bdd_core_module_test.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/reverseproxy/bdd_debug_auth_test.go b/modules/reverseproxy/bdd_debug_auth_test.go index 24864cfe..0312ab92 100644 --- a/modules/reverseproxy/bdd_debug_auth_test.go +++ b/modules/reverseproxy/bdd_debug_auth_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDebugAuthScenarios tests the authenticated debug endpoints BDD scenarios diff --git a/modules/reverseproxy/bdd_events_test.go b/modules/reverseproxy/bdd_events_test.go index 03e57e94..327fdf4d 100644 --- a/modules/reverseproxy/bdd_events_test.go +++ b/modules/reverseproxy/bdd_events_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event Observation Scenarios diff --git a/modules/reverseproxy/bdd_feature_flag_dryrun_test.go b/modules/reverseproxy/bdd_feature_flag_dryrun_test.go index 8891ee57..2a3bc1ed 100644 --- a/modules/reverseproxy/bdd_feature_flag_dryrun_test.go +++ b/modules/reverseproxy/bdd_feature_flag_dryrun_test.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" ) // BDD Test: Feature-flagged composite route with dry-run fallback diff --git a/modules/reverseproxy/bdd_feature_flag_scenarios_test.go b/modules/reverseproxy/bdd_feature_flag_scenarios_test.go index 524afc13..d9372363 100644 --- a/modules/reverseproxy/bdd_feature_flag_scenarios_test.go +++ b/modules/reverseproxy/bdd_feature_flag_scenarios_test.go @@ -7,7 +7,7 @@ import ( "net/http" "net/http/httptest" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Feature Flag Scenario Step Implementations diff --git a/modules/reverseproxy/bdd_feature_flag_steps_test.go b/modules/reverseproxy/bdd_feature_flag_steps_test.go index 4beb0457..0a3af85b 100644 --- a/modules/reverseproxy/bdd_feature_flag_steps_test.go +++ b/modules/reverseproxy/bdd_feature_flag_steps_test.go @@ -4,7 +4,7 @@ import ( "fmt" "sort" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // mockFeatureFlagEvaluator implements FeatureFlagEvaluator for testing diff --git a/modules/reverseproxy/bdd_feature_flags_test.go b/modules/reverseproxy/bdd_feature_flags_test.go index e1cd0fe4..d3c35b20 100644 --- a/modules/reverseproxy/bdd_feature_flags_test.go +++ b/modules/reverseproxy/bdd_feature_flags_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Feature Flag Scenarios diff --git a/modules/reverseproxy/bdd_health_events_test.go b/modules/reverseproxy/bdd_health_events_test.go index 1a02ab35..c57cb2da 100644 --- a/modules/reverseproxy/bdd_health_events_test.go +++ b/modules/reverseproxy/bdd_health_events_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Missing implementation for setting up backends with health checking enabled diff --git a/modules/reverseproxy/bdd_metrics_debug_test.go b/modules/reverseproxy/bdd_metrics_debug_test.go index 67b8bedd..eef01b2e 100644 --- a/modules/reverseproxy/bdd_metrics_debug_test.go +++ b/modules/reverseproxy/bdd_metrics_debug_test.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Metrics Scenarios diff --git a/modules/reverseproxy/bdd_roundrobin_circuit_test.go b/modules/reverseproxy/bdd_roundrobin_circuit_test.go index 73db0fb7..5591798d 100644 --- a/modules/reverseproxy/bdd_roundrobin_circuit_test.go +++ b/modules/reverseproxy/bdd_roundrobin_circuit_test.go @@ -8,7 +8,7 @@ import ( "sync/atomic" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Round-robin with Circuit Breaker BDD Scenarios diff --git a/modules/reverseproxy/bdd_routing_loadbalancing_test.go b/modules/reverseproxy/bdd_routing_loadbalancing_test.go index e930de72..54a44e89 100644 --- a/modules/reverseproxy/bdd_routing_loadbalancing_test.go +++ b/modules/reverseproxy/bdd_routing_loadbalancing_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Single Backend Scenarios diff --git a/modules/reverseproxy/bdd_step_registry_test.go b/modules/reverseproxy/bdd_step_registry_test.go index d6915677..b509bd8b 100644 --- a/modules/reverseproxy/bdd_step_registry_test.go +++ b/modules/reverseproxy/bdd_step_registry_test.go @@ -380,6 +380,31 @@ func registerAllStepDefinitions(s *godog.ScenarioContext, ctx *ReverseProxyBDDTe // Timeout-related scenario steps (removing duplicate to avoid ambiguity) s.Then(`^appropriate timeout error responses should be returned$`, ctx.appropriateTimeoutErrorResponsesShouldBeReturned) + // Pipeline and Fan-Out-Merge Composite Strategy Steps (from bdd_composite_pipeline_test.go) + s.Given(`^I have a pipeline composite route with two backends$`, ctx.iHaveAPipelineCompositeRouteWithTwoBackends) + s.When(`^I send a request to the pipeline route$`, ctx.iSendARequestToThePipelineRoute) + s.Then(`^the first backend should be called with the original request$`, ctx.theFirstBackendShouldBeCalledWithTheOriginalRequest) + s.Then(`^the second backend should receive data derived from the first response$`, ctx.theSecondBackendShouldReceiveDataDerivedFromTheFirstResponse) + s.Then(`^the final response should contain merged data from all stages$`, ctx.theFinalResponseShouldContainMergedDataFromAllStages) + + s.Given(`^I have a fan-out-merge composite route with two backends$`, ctx.iHaveAFanOutMergeCompositeRouteWithTwoBackends) + s.When(`^I send a request to the fan-out-merge route$`, ctx.iSendARequestToTheFanOutMergeRoute) + s.Then(`^both backends should be called in parallel$`, ctx.bothBackendsShouldBeCalledInParallel) + s.Then(`^the responses should be merged by matching IDs$`, ctx.theResponsesShouldBeMergedByMatchingIDs) + s.Then(`^items with matching ancillary data should be enriched$`, ctx.itemsWithMatchingAncillaryDataShouldBeEnriched) + + s.Given(`^I have a pipeline route with skip-empty policy$`, ctx.iHaveAPipelineRouteWithSkipEmptyPolicy) + s.When(`^I send a request and a backend returns an empty response$`, ctx.iSendARequestAndABackendReturnsAnEmptyResponse) + s.Then(`^the empty response should be excluded from the result$`, ctx.theEmptyResponseShouldBeExcludedFromTheResult) + s.Then(`^the non-empty responses should still be present$`, ctx.theNonEmptyResponsesShouldStillBePresent) + + s.Given(`^I have a fan-out-merge route with fail-on-empty policy$`, ctx.iHaveAFanOutMergeRouteWithFailOnEmptyPolicy) + s.Then(`^the request should fail with a bad gateway error$`, ctx.theRequestShouldFailWithABadGatewayError) + + s.Given(`^I have a pipeline route that filters by ancillary backend data$`, ctx.iHaveAPipelineRouteThatFiltersByAncillaryBackendData) + s.When(`^I send a request to fetch filtered results$`, ctx.iSendARequestToFetchFilteredResults) + s.Then(`^only items matching the ancillary criteria should be returned$`, ctx.onlyItemsMatchingTheAncillaryCriteriaShouldBeReturned) + // Note: Most comprehensive step implementations are already in existing BDD files // Only add new steps here for scenarios that are completely missing implementations } diff --git a/modules/reverseproxy/bdd_tenant_caching_override_test.go b/modules/reverseproxy/bdd_tenant_caching_override_test.go index ae4ec0a1..6095f33b 100644 --- a/modules/reverseproxy/bdd_tenant_caching_override_test.go +++ b/modules/reverseproxy/bdd_tenant_caching_override_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/reverseproxy/bdd_tenant_header_enforcement_test.go b/modules/reverseproxy/bdd_tenant_header_enforcement_test.go index 88683deb..d439d059 100644 --- a/modules/reverseproxy/bdd_tenant_header_enforcement_test.go +++ b/modules/reverseproxy/bdd_tenant_header_enforcement_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestTenantHeaderEnforcementBDD runs BDD scenarios for tenant header enforcement diff --git a/modules/reverseproxy/composite.go b/modules/reverseproxy/composite.go index 2aa91982..651bf87f 100644 --- a/modules/reverseproxy/composite.go +++ b/modules/reverseproxy/composite.go @@ -55,6 +55,15 @@ type CompositeHandler struct { responseCache *responseCache eventEmitter func(eventType string, data map[string]interface{}) responseTransformer ResponseTransformer + + // Pipeline strategy configuration + pipelineConfig *PipelineConfig + + // Fan-out-merge strategy merger function + fanOutMerger FanOutMerger + + // Empty response policy for pipeline and fan-out-merge strategies + emptyResponsePolicy EmptyResponsePolicy } // NewCompositeHandler creates a new composite handler with the given backends and strategy. @@ -121,6 +130,21 @@ func (h *CompositeHandler) SetResponseTransformer(transformer ResponseTransforme h.responseTransformer = transformer } +// SetPipelineConfig sets the pipeline configuration for pipeline strategy routes. +func (h *CompositeHandler) SetPipelineConfig(config *PipelineConfig) { + h.pipelineConfig = config +} + +// SetFanOutMerger sets the fan-out merger function for fan-out-merge strategy routes. +func (h *CompositeHandler) SetFanOutMerger(merger FanOutMerger) { + h.fanOutMerger = merger +} + +// SetEmptyResponsePolicy sets the empty response policy for pipeline and fan-out-merge strategies. +func (h *CompositeHandler) SetEmptyResponsePolicy(policy EmptyResponsePolicy) { + h.emptyResponsePolicy = policy +} + // ServeHTTP handles the request by forwarding it to all backends // and merging the responses. func (h *CompositeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -173,6 +197,10 @@ func (h *CompositeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.executeMerge(ctx, recorder, r, bodyBytes) case StrategySequential: h.executeSequential(ctx, recorder, r, bodyBytes) + case StrategyPipeline: + h.executePipeline(ctx, recorder, r, bodyBytes) + case StrategyFanOutMerge: + h.executeFanOutMerge(ctx, recorder, r, bodyBytes) default: // Default to first-success for unknown strategies h.executeFirstSuccess(ctx, recorder, r, bodyBytes) @@ -377,7 +405,7 @@ func (h *CompositeHandler) executeBackendRequest(ctx context.Context, backend *B } // Create a new request with the same method, URL, and headers. - req, err := http.NewRequestWithContext(ctx, r.Method, backendURL, nil) //nolint:gosec // G704: backendURL is built from configured backend.URL, not user input + req, err := http.NewRequestWithContext(ctx, r.Method, backendURL, nil) //nolint:gosec // G704: reverse proxy intentionally forwards requests to configured backends if err != nil { return nil, fmt.Errorf("failed to create new request: %w", err) } @@ -504,6 +532,17 @@ func (m *ReverseProxyModule) createCompositeHandler(ctx context.Context, routeCo // Create and configure the handler handler := NewCompositeHandler(backends, strategy, responseTimeout) + // Set empty response policy from config if specified + if routeConfig.EmptyPolicy != "" { + switch routeConfig.EmptyPolicy { + case string(EmptyResponseAllow), string(EmptyResponseSkip), string(EmptyResponseFail): + handler.SetEmptyResponsePolicy(EmptyResponsePolicy(routeConfig.EmptyPolicy)) + default: + return nil, fmt.Errorf("route %q empty_policy %q: %w", + routeConfig.Pattern, routeConfig.EmptyPolicy, ErrInvalidEmptyResponsePolicy) + } + } + // Set event emitter for circuit breaker events handler.SetEventEmitter(func(eventType string, data map[string]interface{}) { m.emitEvent(ctx, eventType, data) @@ -536,6 +575,21 @@ func (m *ReverseProxyModule) createCompositeHandler(ctx context.Context, routeCo handler.SetResponseTransformer(transformer) } + // Set pipeline config if available for this route + if pipelineCfg, exists := m.pipelineConfigs[routeConfig.Pattern]; exists { + handler.SetPipelineConfig(pipelineCfg) + } + + // Set fan-out merger if available for this route + if merger, exists := m.fanOutMergers[routeConfig.Pattern]; exists { + handler.SetFanOutMerger(merger) + } + + // Set empty response policy if available for this route + if policy, exists := m.emptyResponsePolicies[routeConfig.Pattern]; exists { + handler.SetEmptyResponsePolicy(policy) + } + return handler, nil } diff --git a/modules/reverseproxy/composite_pipeline.go b/modules/reverseproxy/composite_pipeline.go new file mode 100644 index 00000000..bf38f35f --- /dev/null +++ b/modules/reverseproxy/composite_pipeline.go @@ -0,0 +1,417 @@ +// Package reverseproxy provides a flexible reverse proxy module with support for multiple backends, +// composite responses, and tenant awareness. +package reverseproxy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" +) + +const ( + // StrategyPipeline executes backends sequentially where each stage's response + // can inform the next stage's request. This enables map/reduce patterns where + // backend B's request is constructed from backend A's response. + // + // Example: Backend A returns a list of conversation IDs, backend B is called + // with those IDs to fetch ancillary details, and the responses are merged. + // + // Requires a PipelineConfig to be set via SetPipelineConfig. + StrategyPipeline CompositeStrategy = "pipeline" + + // StrategyFanOutMerge executes all backend requests in parallel (like merge), + // then applies a custom FanOutMerger function to perform ID-based matching, + // filtering, and complex merging logic across all responses. + // + // Example: Backend A returns conversations, backend B returns follow-up flags. + // The merger matches by conversation ID and produces a unified response. + // + // Requires a FanOutMerger to be set via SetFanOutMerger. + StrategyFanOutMerge CompositeStrategy = "fan-out-merge" +) + +// EmptyResponsePolicy defines how empty backend responses should be handled +// in pipeline and fan-out-merge strategies. +type EmptyResponsePolicy string + +const ( + // EmptyResponseAllow includes empty responses in the result set. + // Backends that return no data are represented as empty/nil in the response map. + EmptyResponseAllow EmptyResponsePolicy = "allow-empty" + + // EmptyResponseSkip silently drops empty responses from the result set. + // The merger/pipeline receives only non-empty responses. + EmptyResponseSkip EmptyResponsePolicy = "skip-empty" + + // EmptyResponseFail causes the entire composite request to fail if any backend + // returns an empty response. Returns 502 Bad Gateway. + EmptyResponseFail EmptyResponsePolicy = "fail-on-empty" +) + +// PipelineRequestBuilder builds the HTTP request for the next pipeline stage. +// It receives: +// - ctx: the request context +// - originalReq: the original incoming HTTP request +// - previousResponses: accumulated parsed response bodies keyed by backend ID +// - nextBackendID: the ID of the next backend to call +// +// It returns the HTTP request to send to the next backend, or an error. +// If it returns nil for the request (with no error), the stage is skipped. +type PipelineRequestBuilder func( + ctx context.Context, + originalReq *http.Request, + previousResponses map[string][]byte, + nextBackendID string, +) (*http.Request, error) + +// PipelineResponseMerger merges all pipeline stage responses into a single HTTP response. +// It receives: +// - ctx: the request context +// - originalReq: the original incoming HTTP request +// - allResponses: all accumulated response bodies keyed by backend ID +// +// It returns the final merged HTTP response, or an error. +type PipelineResponseMerger func( + ctx context.Context, + originalReq *http.Request, + allResponses map[string][]byte, +) (*http.Response, error) + +// FanOutMerger merges parallel backend responses using custom logic such as +// ID-based matching, filtering, or complex data correlation. +// It receives: +// - ctx: the request context +// - originalReq: the original incoming HTTP request +// - responses: response bodies keyed by backend ID +// +// It returns the final merged HTTP response, or an error. +type FanOutMerger func( + ctx context.Context, + originalReq *http.Request, + responses map[string][]byte, +) (*http.Response, error) + +// PipelineConfig holds configuration for a pipeline strategy route. +type PipelineConfig struct { + // RequestBuilder constructs the request for each subsequent pipeline stage + // using responses from previous stages. + RequestBuilder PipelineRequestBuilder + + // ResponseMerger combines all pipeline stage responses into a final response. + // If nil, a default merger is used that wraps all responses in a JSON object + // keyed by backend ID. + ResponseMerger PipelineResponseMerger +} + +// isEmptyBody returns true if the body bytes represent an empty or null response. +func isEmptyBody(body []byte) bool { + if len(body) == 0 { + return true + } + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return true + } + // Check for JSON null + if string(trimmed) == "null" { + return true + } + // Check for empty JSON object + if string(trimmed) == "{}" { + return true + } + // Check for empty JSON array + if string(trimmed) == "[]" { + return true + } + return false +} + +// executePipeline executes backends sequentially, passing each response to the +// PipelineRequestBuilder to construct the next request. +func (h *CompositeHandler) executePipeline(ctx context.Context, w http.ResponseWriter, r *http.Request, bodyBytes []byte) { + if h.pipelineConfig == nil || h.pipelineConfig.RequestBuilder == nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Pipeline strategy requires a PipelineConfig with RequestBuilder")) + return + } + + allResponses := make(map[string][]byte) + + for i, backend := range h.backends { + // Check the circuit breaker before making the request. + circuitBreaker := h.circuitBreakers[backend.ID] + if circuitBreaker != nil && circuitBreaker.IsOpen() { + continue + } + + var req *http.Request + var err error + + if i == 0 { + // First stage: use the original request + req, err = h.buildBackendRequest(ctx, backend, r, bodyBytes) + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + } else { + // Subsequent stages: use the PipelineRequestBuilder + req, err = h.pipelineConfig.RequestBuilder(ctx, r, allResponses, backend.ID) + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + // If builder returns nil, skip this stage + if req == nil { + continue + } + } + + // Execute the request using the backend's client + resp, err := backend.Client.Do(req) //nolint:gosec // G704: reverse proxy intentionally forwards requests to configured backends + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + + // Read and store the response body + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + continue + } + + // Record success in the circuit breaker. + if circuitBreaker != nil { + circuitBreaker.RecordSuccess() + } + + // Apply empty response policy + if isEmptyBody(respBody) { + switch h.emptyResponsePolicy { + case EmptyResponseFail: + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "Backend %s returned empty response", backend.ID) + return + case EmptyResponseSkip: + continue + case EmptyResponseAllow: + // Include empty response + default: + // Include empty response + } + } + + allResponses[backend.ID] = respBody + } + + // Merge all responses + if h.pipelineConfig.ResponseMerger != nil { + mergedResp, err := h.pipelineConfig.ResponseMerger(ctx, r, allResponses) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Pipeline response merge failed: %v", err) + return + } + if mergedResp != nil { + h.writeResponse(mergedResp, w) + mergedResp.Body.Close() + return + } + } + + // Default: wrap all responses in a JSON object keyed by backend ID + h.writeDefaultPipelineResponse(allResponses, w) +} + +// executeFanOutMerge executes all backend requests in parallel, reads their bodies, +// then applies the FanOutMerger to produce the final response. +func (h *CompositeHandler) executeFanOutMerge(ctx context.Context, w http.ResponseWriter, r *http.Request, bodyBytes []byte) { + if h.fanOutMerger == nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Fan-out-merge strategy requires a FanOutMerger")) + return + } + + var wg sync.WaitGroup + var mu sync.Mutex + responses := make(map[string][]byte) + + for _, backend := range h.backends { + b := backend + wg.Go(func() { + // Check the circuit breaker + circuitBreaker := h.circuitBreakers[b.ID] + if circuitBreaker != nil && circuitBreaker.IsOpen() { + return + } + + // Execute the request + resp, err := h.executeBackendRequest(ctx, b, r, bodyBytes) //nolint:bodyclose // Response body is closed below + if err != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + return + } + + // Read the response body + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + if circuitBreaker != nil { + circuitBreaker.RecordFailure() + } + return + } + + // Record success + if circuitBreaker != nil { + circuitBreaker.RecordSuccess() + } + + mu.Lock() + responses[b.ID] = body + mu.Unlock() + }) + } + + wg.Wait() + + // Short-circuit if all backends failed or were skipped by open circuit breakers + if len(responses) == 0 { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "No successful responses from fan-out backends") + return + } + + // Apply empty response policy + filteredResponses := make(map[string][]byte) + for backendID, body := range responses { + if isEmptyBody(body) { + switch h.emptyResponsePolicy { + case EmptyResponseFail: + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "Backend %s returned empty response", backendID) + return + case EmptyResponseSkip: + continue + case EmptyResponseAllow: + filteredResponses[backendID] = body + default: + filteredResponses[backendID] = body + } + } else { + filteredResponses[backendID] = body + } + } + + // Short-circuit if all responses were filtered out (e.g., all empty with skip policy) + if len(filteredResponses) == 0 { + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "No non-empty responses from fan-out backends") + return + } + + // Apply the fan-out merger + mergedResp, err := h.fanOutMerger(ctx, r, filteredResponses) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Fan-out merge failed: %v", err) + return + } + if mergedResp != nil { + h.writeResponse(mergedResp, w) + mergedResp.Body.Close() + return + } + + // If merger returned nil, return empty response + w.WriteHeader(http.StatusNoContent) +} + +// buildBackendRequest creates an HTTP request for a backend (used by pipeline for the first stage). +func (h *CompositeHandler) buildBackendRequest(ctx context.Context, backend *Backend, r *http.Request, bodyBytes []byte) (*http.Request, error) { + backendURL := backend.URL + r.URL.Path + if r.URL.RawQuery != "" { + backendURL += "?" + r.URL.RawQuery + } + + req, err := http.NewRequestWithContext(ctx, r.Method, backendURL, nil) //nolint:gosec // G704: reverse proxy intentionally forwards requests to configured backends + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range r.Header { + for _, val := range v { + req.Header.Add(k, val) + } + } + + if len(bodyBytes) > 0 { + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + req.ContentLength = int64(len(bodyBytes)) + } + + return req, nil +} + +// writeDefaultPipelineResponse writes a default JSON response containing all pipeline stage responses. +func (h *CompositeHandler) writeDefaultPipelineResponse(allResponses map[string][]byte, w http.ResponseWriter) { + if len(allResponses) == 0 { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("No successful responses from pipeline backends")) + return + } + + merged := make(map[string]interface{}) + for backendID, body := range allResponses { + var data interface{} + if err := json.Unmarshal(body, &data); err != nil { + merged[backendID] = string(body) + } else { + merged[backendID] = data + } + } + + encoded, err := json.Marshal(merged) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Failed to encode pipeline response")) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(encoded) +} + +// MakeJSONResponse is a helper that creates an HTTP response from a JSON-serializable value. +// It's provided for use by PipelineResponseMerger and FanOutMerger implementations. +func MakeJSONResponse(statusCode int, data interface{}) (*http.Response, error) { + body, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return &http.Response{ + Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)), + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil +} diff --git a/modules/reverseproxy/composite_pipeline_test.go b/modules/reverseproxy/composite_pipeline_test.go new file mode 100644 index 00000000..ad034ca5 --- /dev/null +++ b/modules/reverseproxy/composite_pipeline_test.go @@ -0,0 +1,1676 @@ +package reverseproxy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================ +// Pipeline Strategy Tests +// ============================================================================ + +func TestPipelineStrategy_BasicChaining(t *testing.T) { + // Backend A returns a list of conversation IDs + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "conv-1", "title": "First conversation"}, + {"id": "conv-2", "title": "Second conversation"}, + {"id": "conv-3", "title": "Third conversation"}, + }, + }) + })) + defer backendA.Close() + + // Backend B returns follow-up details for given IDs (received via query params) + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ids := r.URL.Query().Get("ids") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Return follow-up details for the requested IDs + followUps := map[string]interface{}{} + for _, id := range strings.Split(ids, ",") { + if id == "conv-1" { + followUps[id] = map[string]interface{}{"is_followup": true, "original_id": "conv-0"} + } + // conv-2 and conv-3 have no follow-up data + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "follow_ups": followUps, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "conversations", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "followups", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "followups" { + // Parse conversation IDs from the previous response + var convResp struct { + Conversations []struct { + ID string `json:"id"` + } `json:"conversations"` + } + if convBody, ok := previousResponses["conversations"]; ok { + if err := json.Unmarshal(convBody, &convResp); err != nil { + return nil, fmt.Errorf("failed to parse conversations: %w", err) + } + } + + // Build query with IDs + ids := make([]string, 0, len(convResp.Conversations)) + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + + url := backends[1].URL + "/followups?ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + // Parse both responses + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + if convBody, ok := allResponses["conversations"]; ok { + json.Unmarshal(convBody, &convResp) + } + + var followUpResp struct { + FollowUps map[string]interface{} `json:"follow_ups"` + } + if fuBody, ok := allResponses["followups"]; ok { + json.Unmarshal(fuBody, &followUpResp) + } + + // Merge follow-up data into conversations + for i, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok { + if fu, exists := followUpResp.FollowUps[id]; exists { + convResp.Conversations[i]["follow_up"] = fu + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "conversations": convResp.Conversations, + }) + }, + }) + + req := httptest.NewRequest("GET", "/api/conversations", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + require.NoError(t, err) + + conversations, ok := result["conversations"].([]interface{}) + require.True(t, ok, "expected conversations array") + assert.Len(t, conversations, 3) + + // Verify conv-1 has follow-up data + conv1 := conversations[0].(map[string]interface{}) + assert.Equal(t, "conv-1", conv1["id"]) + followUp, hasFollowUp := conv1["follow_up"] + assert.True(t, hasFollowUp, "conv-1 should have follow_up data") + fuMap := followUp.(map[string]interface{}) + assert.Equal(t, true, fuMap["is_followup"]) + + // Verify conv-2 has no follow-up data + conv2 := conversations[1].(map[string]interface{}) + assert.Equal(t, "conv-2", conv2["id"]) + _, hasFollowUp2 := conv2["follow_up"] + assert.False(t, hasFollowUp2, "conv-2 should not have follow_up data") +} + +func TestPipelineStrategy_ThreeStageChain(t *testing.T) { + // Stage 1: returns user IDs + stage1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": []string{"user-1", "user-2"}, + }) + })) + defer stage1.Close() + + // Stage 2: returns user profiles + stage2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "profiles": map[string]interface{}{ + "user-1": map[string]interface{}{"name": "Alice", "dept": "eng"}, + "user-2": map[string]interface{}{"name": "Bob", "dept": "sales"}, + }, + }) + })) + defer stage2.Close() + + // Stage 3: returns permissions + stage3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "permissions": map[string]interface{}{ + "user-1": []string{"admin", "read", "write"}, + "user-2": []string{"read"}, + }, + }) + })) + defer stage3.Close() + + backends := []*Backend{ + {ID: "users", URL: stage1.URL, Client: http.DefaultClient}, + {ID: "profiles", URL: stage2.URL, Client: http.DefaultClient}, + {ID: "permissions", URL: stage3.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + switch nextBackendID { + case "profiles": + url := backends[1].URL + "/profiles" + return http.NewRequestWithContext(ctx, "GET", url, nil) + case "permissions": + url := backends[2].URL + "/permissions" + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + result := make(map[string]interface{}) + for k, v := range allResponses { + var parsed interface{} + json.Unmarshal(v, &parsed) + result[k] = parsed + } + return MakeJSONResponse(http.StatusOK, result) + }, + }) + + req := httptest.NewRequest("GET", "/api/users", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // All three stages should be present + assert.Contains(t, result, "users") + assert.Contains(t, result, "profiles") + assert.Contains(t, result, "permissions") +} + +func TestPipelineStrategy_NoPipelineConfig(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + handler := NewCompositeHandler(backends, StrategyPipeline, 5*time.Second) + // Intentionally not setting pipeline config + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestPipelineStrategy_DefaultMerger(t *testing.T) { + // When no ResponseMerger is set, the default wraps responses by backend ID + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"step":"one"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"step":"two"}`)) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "step1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "step2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/step2", nil) + }, + // No ResponseMerger - uses default + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Contains(t, result, "step1") + assert.Contains(t, result, "step2") +} + +func TestPipelineStrategy_SkipStage(t *testing.T) { + // When PipelineRequestBuilder returns nil, the stage is skipped + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage1"}`)) + })) + defer backend1.Close() + + callCount := 0 + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage2"}`)) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "stage1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "stage2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + // Skip stage2 by returning nil + return nil, nil + }, + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Stage 2 should not have been called + assert.Equal(t, 0, callCount, "stage2 should not have been called") + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Contains(t, result, "stage1") + assert.NotContains(t, result, "stage2") +} + +func TestPipelineStrategy_BackendError(t *testing.T) { + // First backend succeeds, second fails + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"from-stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "stage1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "stage2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + // Pipeline should still return stage1 results even if stage2 fails + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + assert.Contains(t, result, "stage1") +} + +func TestPipelineStrategy_RequestBuilderError(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"ok"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage2"}`)) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "stage1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "stage2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, fmt.Errorf("intentional builder error") + }, + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + // Should still return stage1 data despite stage2 builder error + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +// ============================================================================ +// Fan-Out-Merge Strategy Tests +// ============================================================================ + +func TestFanOutMerge_IDBasedMerging(t *testing.T) { + // Backend A returns conversations + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{ + {"id": "item-1", "name": "Item One", "status": "active"}, + {"id": "item-2", "name": "Item Two", "status": "pending"}, + {"id": "item-3", "name": "Item Three", "status": "active"}, + }, + }) + })) + defer backendA.Close() + + // Backend B returns ancillary details (some items may not be present) + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "details": map[string]interface{}{ + "item-1": map[string]interface{}{"priority": "high", "assignee": "Alice"}, + "item-3": map[string]interface{}{"priority": "low", "assignee": "Bob"}, + }, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "items", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "details", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Parse items + var itemsResp struct { + Items []map[string]interface{} `json:"items"` + } + if body, ok := responses["items"]; ok { + json.Unmarshal(body, &itemsResp) + } + + // Parse details + var detailsResp struct { + Details map[string]interface{} `json:"details"` + } + if body, ok := responses["details"]; ok { + json.Unmarshal(body, &detailsResp) + } + + // Merge by ID + for i, item := range itemsResp.Items { + if id, ok := item["id"].(string); ok { + if detail, exists := detailsResp.Details[id]; exists { + itemsResp.Items[i]["details"] = detail + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "items": itemsResp.Items, + }) + }) + + req := httptest.NewRequest("GET", "/api/items", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + items := result["items"].([]interface{}) + assert.Len(t, items, 3) + + // item-1 should have details + item1 := items[0].(map[string]interface{}) + assert.Equal(t, "item-1", item1["id"]) + assert.NotNil(t, item1["details"]) + + // item-2 should NOT have details + item2 := items[1].(map[string]interface{}) + assert.Equal(t, "item-2", item2["id"]) + _, hasDetails := item2["details"] + assert.False(t, hasDetails) + + // item-3 should have details + item3 := items[2].(map[string]interface{}) + assert.Equal(t, "item-3", item3["id"]) + assert.NotNil(t, item3["details"]) +} + +func TestFanOutMerge_FilterByAncillaryData(t *testing.T) { + // Backend A returns all conversations + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "c1", "title": "Conversation 1"}, + {"id": "c2", "title": "Conversation 2"}, + {"id": "c3", "title": "Conversation 3"}, + }, + }) + })) + defer backendA.Close() + + // Backend B returns which conversations are follow-ups (acts as filter) + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "follow_up_ids": []string{"c1", "c3"}, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "conversations", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "followups", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + } + if body, ok := responses["conversations"]; ok { + json.Unmarshal(body, &convResp) + } + + var fuResp struct { + FollowUpIDs []string `json:"follow_up_ids"` + } + if body, ok := responses["followups"]; ok { + json.Unmarshal(body, &fuResp) + } + + // Create lookup set + followUpSet := make(map[string]bool) + for _, id := range fuResp.FollowUpIDs { + followUpSet[id] = true + } + + // Filter: only include conversations that are follow-ups + var filtered []map[string]interface{} + for _, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok && followUpSet[id] { + conv["is_follow_up"] = true + filtered = append(filtered, conv) + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "follow_up_conversations": filtered, + }) + }) + + req := httptest.NewRequest("GET", "/api/followups", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + convs := result["follow_up_conversations"].([]interface{}) + assert.Len(t, convs, 2, "only c1 and c3 should be included") + + ids := make([]string, 0, len(convs)) + for _, c := range convs { + ids = append(ids, c.(map[string]interface{})["id"].(string)) + } + assert.Contains(t, ids, "c1") + assert.Contains(t, ids, "c3") +} + +func TestFanOutMerge_NoMerger(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 5*time.Second) + // Intentionally not setting fan-out merger + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestFanOutMerge_ThreeBackends(t *testing.T) { + // Three backends returning different types of data for the same entity + backendUsers := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "users": []map[string]interface{}{ + {"id": "u1", "name": "Alice"}, + {"id": "u2", "name": "Bob"}, + }, + }) + })) + defer backendUsers.Close() + + backendRoles := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "roles": map[string]string{ + "u1": "admin", + "u2": "user", + }, + }) + })) + defer backendRoles.Close() + + backendActivity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "activity": map[string]int{ + "u1": 42, + "u2": 7, + }, + }) + })) + defer backendActivity.Close() + + backends := []*Backend{ + {ID: "users", URL: backendUsers.URL, Client: http.DefaultClient}, + {ID: "roles", URL: backendRoles.URL, Client: http.DefaultClient}, + {ID: "activity", URL: backendActivity.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var usersResp struct { + Users []map[string]interface{} `json:"users"` + } + json.Unmarshal(responses["users"], &usersResp) + + var rolesResp struct { + Roles map[string]string `json:"roles"` + } + json.Unmarshal(responses["roles"], &rolesResp) + + var activityResp struct { + Activity map[string]float64 `json:"activity"` + } + json.Unmarshal(responses["activity"], &activityResp) + + // Enrich users with roles and activity + for i, user := range usersResp.Users { + id := user["id"].(string) + if role, ok := rolesResp.Roles[id]; ok { + usersResp.Users[i]["role"] = role + } + if count, ok := activityResp.Activity[id]; ok { + usersResp.Users[i]["activity_count"] = count + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "enriched_users": usersResp.Users, + }) + }) + + req := httptest.NewRequest("GET", "/api/users", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + users := result["enriched_users"].([]interface{}) + assert.Len(t, users, 2) + + u1 := users[0].(map[string]interface{}) + assert.Equal(t, "Alice", u1["name"]) + assert.Equal(t, "admin", u1["role"]) + assert.Equal(t, float64(42), u1["activity_count"]) + + u2 := users[1].(map[string]interface{}) + assert.Equal(t, "Bob", u2["name"]) + assert.Equal(t, "user", u2["role"]) + assert.Equal(t, float64(7), u2["activity_count"]) +} + +// ============================================================================ +// Empty Response Policy Tests +// ============================================================================ + +func TestEmptyResponsePolicy_AllowEmpty_Pipeline(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty JSON object + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "s1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "s2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseAllow) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // Both stages should be present + assert.Contains(t, result, "s1") + assert.Contains(t, result, "s2") +} + +func TestEmptyResponsePolicy_SkipEmpty_Pipeline(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty JSON object + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "s1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "s2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseSkip) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // Only s1 should be present; s2 was empty and skipped + assert.Contains(t, result, "s1") + assert.NotContains(t, result, "s2") +} + +func TestEmptyResponsePolicy_FailOnEmpty_Pipeline(t *testing.T) { + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"stage1"}`)) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) // Empty JSON array + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "s1", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "s2", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseFail) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return http.NewRequestWithContext(ctx, "GET", backends[1].URL+"/test", nil) + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + // Should fail because stage 2 returned empty + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestEmptyResponsePolicy_SkipEmpty_FanOutMerge(t *testing.T) { + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []string{"a", "b", "c"}, + }) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`null`)) // Empty/null response + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "primary", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "ancillary", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseSkip) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // ancillary should be skipped + _, hasAncillary := responses["ancillary"] + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "has_ancillary": hasAncillary, + "backends": len(responses), + }) + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Equal(t, false, result["has_ancillary"]) + assert.Equal(t, float64(1), result["backends"]) +} + +func TestEmptyResponsePolicy_FailOnEmpty_FanOutMerge(t *testing.T) { + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":"ok"}`)) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(``)) // Completely empty body + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "primary", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "ancillary", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseFail) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return MakeJSONResponse(http.StatusOK, responses) + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +// ============================================================================ +// isEmptyBody Tests +// ============================================================================ + +func TestIsEmptyBody(t *testing.T) { + tests := []struct { + name string + body []byte + expected bool + }{ + {"nil body", nil, true}, + {"empty body", []byte{}, true}, + {"whitespace only", []byte(" \n\t "), true}, + {"null JSON", []byte("null"), true}, + {"empty object", []byte("{}"), true}, + {"empty array", []byte("[]"), true}, + {"null with whitespace", []byte(" null "), true}, + {"non-empty object", []byte(`{"key":"value"}`), false}, + {"non-empty array", []byte(`[1,2,3]`), false}, + {"string value", []byte(`"hello"`), false}, + {"number value", []byte(`42`), false}, + {"boolean true", []byte(`true`), false}, + {"object with empty string", []byte(`{"key":""}`), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isEmptyBody(tt.body) + assert.Equal(t, tt.expected, result) + }) + } +} + +// ============================================================================ +// MakeJSONResponse Helper Tests +// ============================================================================ + +func TestMakeJSONResponse(t *testing.T) { + data := map[string]interface{}{ + "key": "value", + "num": 42, + } + + resp, err := MakeJSONResponse(http.StatusOK, data) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var result map[string]interface{} + json.Unmarshal(body, &result) + assert.Equal(t, "value", result["key"]) + assert.Equal(t, float64(42), result["num"]) +} + +func TestMakeJSONResponse_CustomStatusCode(t *testing.T) { + resp, err := MakeJSONResponse(http.StatusCreated, map[string]string{"status": "created"}) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + resp.Body.Close() +} + +// ============================================================================ +// Complex Scenario Tests +// ============================================================================ + +func TestPipelineStrategy_ConversationListWithFollowUps(t *testing.T) { + // Scenario from the issue: list page with queued conversations + // Backend A has general conversation details + // Backend B has ancillary details (follow-ups) + + conversationsBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "conversations": []map[string]interface{}{ + {"id": "c-100", "status": "queued", "counselor": "Alice", "created_at": "2024-01-01T10:00:00Z"}, + {"id": "c-101", "status": "queued", "counselor": "Bob", "created_at": "2024-01-01T10:05:00Z"}, + {"id": "c-102", "status": "active", "counselor": "Carol", "created_at": "2024-01-01T10:10:00Z"}, + {"id": "c-103", "status": "queued", "counselor": nil, "created_at": "2024-01-01T10:15:00Z"}, + }, + "total": 4, + "page": 1, + }) + })) + defer conversationsBackend.Close() + + followUpBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // This backend receives specific conversation IDs to check + idsParam := r.URL.Query().Get("conversation_ids") + ids := strings.Split(idsParam, ",") + + followUps := make(map[string]interface{}) + // c-100 is a follow-up to c-50 + for _, id := range ids { + if id == "c-100" { + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "c-50", + "follow_up_count": 2, + } + } + if id == "c-103" { + followUps[id] = map[string]interface{}{ + "is_follow_up": true, + "original_conv_id": "c-90", + "follow_up_count": 1, + } + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "follow_ups": followUps, + }) + })) + defer followUpBackend.Close() + + backends := []*Backend{ + {ID: "conversations", URL: conversationsBackend.URL, Client: http.DefaultClient}, + {ID: "followups", URL: followUpBackend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "followups" { + // Extract conversation IDs from the first response + var convResp struct { + Conversations []struct { + ID string `json:"id"` + } `json:"conversations"` + } + if body, ok := previousResponses["conversations"]; ok { + if err := json.Unmarshal(body, &convResp); err != nil { + return nil, err + } + } + + ids := make([]string, 0, len(convResp.Conversations)) + for _, c := range convResp.Conversations { + ids = append(ids, c.ID) + } + + url := followUpBackend.URL + "/followups?conversation_ids=" + strings.Join(ids, ",") + return http.NewRequestWithContext(ctx, "GET", url, nil) + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + var convResp struct { + Conversations []map[string]interface{} `json:"conversations"` + Total int `json:"total"` + Page int `json:"page"` + } + json.Unmarshal(allResponses["conversations"], &convResp) + + var fuResp struct { + FollowUps map[string]interface{} `json:"follow_ups"` + } + json.Unmarshal(allResponses["followups"], &fuResp) + + // Enrich conversations with follow-up data + for i, conv := range convResp.Conversations { + if id, ok := conv["id"].(string); ok { + if fu, exists := fuResp.FollowUps[id]; exists { + convResp.Conversations[i]["follow_up_info"] = fu + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "conversations": convResp.Conversations, + "total": convResp.Total, + "page": convResp.Page, + }) + }, + }) + + req := httptest.NewRequest("GET", "/api/conversations?status=queued", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + conversations := result["conversations"].([]interface{}) + assert.Len(t, conversations, 4) + + // c-100 should have follow_up_info + c100 := conversations[0].(map[string]interface{}) + assert.Equal(t, "c-100", c100["id"]) + fuInfo, hasFU := c100["follow_up_info"] + assert.True(t, hasFU) + fuMap := fuInfo.(map[string]interface{}) + assert.Equal(t, true, fuMap["is_follow_up"]) + assert.Equal(t, "c-50", fuMap["original_conv_id"]) + + // c-101 should NOT have follow_up_info + c101 := conversations[1].(map[string]interface{}) + _, hasFU101 := c101["follow_up_info"] + assert.False(t, hasFU101) + + // c-103 should have follow_up_info + c103 := conversations[3].(map[string]interface{}) + assert.Equal(t, "c-103", c103["id"]) + _, hasFU103 := c103["follow_up_info"] + assert.True(t, hasFU103) +} + +func TestFanOutMerge_ComplexNestedResponses(t *testing.T) { + // Complex scenario: merging nested JSON structures + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "orders": []map[string]interface{}{ + { + "id": "ord-1", + "amount": 99.99, + "items": []map[string]interface{}{ + {"sku": "SKU-001", "qty": 2}, + {"sku": "SKU-002", "qty": 1}, + }, + }, + { + "id": "ord-2", + "amount": 149.50, + "items": []map[string]interface{}{ + {"sku": "SKU-003", "qty": 3}, + }, + }, + }, + }) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "shipping": map[string]interface{}{ + "ord-1": map[string]interface{}{ + "status": "shipped", + "tracking": "TRACK-12345", + "carrier": "FedEx", + }, + "ord-2": map[string]interface{}{ + "status": "processing", + "tracking": "", + "carrier": "", + }, + }, + }) + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "orders", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "shipping", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var ordersResp struct { + Orders []map[string]interface{} `json:"orders"` + } + json.Unmarshal(responses["orders"], &ordersResp) + + var shippingResp struct { + Shipping map[string]interface{} `json:"shipping"` + } + json.Unmarshal(responses["shipping"], &shippingResp) + + for i, order := range ordersResp.Orders { + if id, ok := order["id"].(string); ok { + if shipping, exists := shippingResp.Shipping[id]; exists { + ordersResp.Orders[i]["shipping"] = shipping + } + } + } + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "orders": ordersResp.Orders, + }) + }) + + req := httptest.NewRequest("GET", "/api/orders", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + orders := result["orders"].([]interface{}) + assert.Len(t, orders, 2) + + ord1 := orders[0].(map[string]interface{}) + shipping := ord1["shipping"].(map[string]interface{}) + assert.Equal(t, "shipped", shipping["status"]) + assert.Equal(t, "TRACK-12345", shipping["tracking"]) +} + +func TestFanOutMerge_EmptyAncillaryData_AllowPolicy(t *testing.T) { + // Backend A returns data, Backend B returns empty (no ancillary data exists) + // With allow-empty policy, merger should handle gracefully + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []map[string]interface{}{ + {"id": "x1", "name": "X1"}, + }, + }) + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty response - no ancillary data + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "primary", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "ancillary", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseAllow) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Both responses should be present + _, hasPrimary := responses["primary"] + _, hasAncillary := responses["ancillary"] + + return MakeJSONResponse(http.StatusOK, map[string]interface{}{ + "primary_present": hasPrimary, + "ancillary_present": hasAncillary, + }) + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + assert.Equal(t, true, result["primary_present"]) + assert.Equal(t, true, result["ancillary_present"]) +} + +func TestPipelineStrategy_WithRequestBody(t *testing.T) { + // Test pipeline with POST request containing body + backend1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var input map[string]interface{} + json.Unmarshal(body, &input) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "processed": true, + "input": input, + "result_id": "res-123", + }) + })) + defer backend1.Close() + + backend2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var input map[string]interface{} + json.Unmarshal(body, &input) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "stored": true, + "result_id": input["result_id"], + }) + })) + defer backend2.Close() + + backends := []*Backend{ + {ID: "process", URL: backend1.URL, Client: http.DefaultClient}, + {ID: "store", URL: backend2.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + if nextBackendID == "store" { + // Parse the result_id from process response + var processResp struct { + ResultID string `json:"result_id"` + } + json.Unmarshal(previousResponses["process"], &processResp) + + // Build store request with result_id + storeBody, _ := json.Marshal(map[string]interface{}{ + "result_id": processResp.ResultID, + "action": "save", + }) + req, _ := http.NewRequestWithContext(ctx, "POST", backends[1].URL+"/store", + bytes.NewReader(storeBody)) + req.Header.Set("Content-Type", "application/json") + return req, nil + } + return nil, fmt.Errorf("unknown backend: %s", nextBackendID) + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + result := make(map[string]interface{}) + for k, v := range allResponses { + var parsed interface{} + json.Unmarshal(v, &parsed) + result[k] = parsed + } + return MakeJSONResponse(http.StatusOK, result) + }, + }) + + inputBody := `{"data":"test-payload"}` + req := httptest.NewRequest("POST", "/api/process", strings.NewReader(inputBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + + // Verify process stage + processResult := result["process"].(map[string]interface{}) + assert.Equal(t, true, processResult["processed"]) + assert.Equal(t, "res-123", processResult["result_id"]) + + // Verify store stage received the result_id from process stage + storeResult := result["store"].(map[string]interface{}) + assert.Equal(t, true, storeResult["stored"]) + assert.Equal(t, "res-123", storeResult["result_id"]) +} + +// ============================================================================ +// Module Integration Tests +// ============================================================================ + +func TestModuleSetPipelineConfig(t *testing.T) { + module := NewModule() + + config := PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, nil + }, + } + + module.SetPipelineConfig("/api/pipeline", config) + assert.NotNil(t, module.pipelineConfigs["/api/pipeline"]) +} + +func TestModuleSetFanOutMerger(t *testing.T) { + module := NewModule() + + merger := func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return MakeJSONResponse(http.StatusOK, responses) + } + + module.SetFanOutMerger("/api/fanout", merger) + assert.NotNil(t, module.fanOutMergers["/api/fanout"]) +} + +func TestModuleSetEmptyResponsePolicy(t *testing.T) { + module := NewModule() + + module.SetEmptyResponsePolicy("/api/test", EmptyResponseSkip) + assert.Equal(t, EmptyResponseSkip, module.emptyResponsePolicies["/api/test"]) +} + +// ============================================================================ +// Edge Case Tests +// ============================================================================ + +func TestPipeline_AllBackendsFail(t *testing.T) { + // When all backends fail, we should get a bad gateway + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: "http://localhost:1", Client: &http.Client{Timeout: 100 * time.Millisecond}}, // Will fail + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 5*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, nil + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestFanOutMerge_SingleBackend(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"single": true}) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "only", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + var data interface{} + json.Unmarshal(responses["only"], &data) + return MakeJSONResponse(http.StatusOK, data) + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + json.Unmarshal(body, &result) + assert.Equal(t, true, result["single"]) +} + +func TestFanOutMerge_MergerError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + return nil, fmt.Errorf("intentional merger error") + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestPipelineStrategy_ResponseMergerError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer backend.Close() + + backends := []*Backend{ + {ID: "b1", URL: backend.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyPipeline, 10*time.Second) + handler.SetPipelineConfig(&PipelineConfig{ + RequestBuilder: func(ctx context.Context, originalReq *http.Request, previousResponses map[string][]byte, nextBackendID string) (*http.Request, error) { + return nil, nil + }, + ResponseMerger: func(ctx context.Context, originalReq *http.Request, allResponses map[string][]byte) (*http.Response, error) { + return nil, fmt.Errorf("intentional merger error") + }, + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} + +func TestFanOutMerge_AllBackendsFail_Returns502(t *testing.T) { + // When all backends fail (unreachable), executeFanOutMerge should return 502 + backends := []*Backend{ + {ID: "b1", URL: "http://127.0.0.1:1", Client: &http.Client{Timeout: 50 * time.Millisecond}}, + {ID: "b2", URL: "http://127.0.0.1:1", Client: &http.Client{Timeout: 50 * time.Millisecond}}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 5*time.Second) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Should never be called since all backends fail + return MakeJSONResponse(http.StatusOK, map[string]interface{}{"ok": true}) + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestFanOutMerge_AllEmptyWithSkipPolicy_Returns502(t *testing.T) { + // When all responses are empty and skip-empty policy is set, return 502 + backendA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`null`)) // Empty/null + })) + defer backendA.Close() + + backendB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) // Empty object + })) + defer backendB.Close() + + backends := []*Backend{ + {ID: "a", URL: backendA.URL, Client: http.DefaultClient}, + {ID: "b", URL: backendB.URL, Client: http.DefaultClient}, + } + + handler := NewCompositeHandler(backends, StrategyFanOutMerge, 10*time.Second) + handler.SetEmptyResponsePolicy(EmptyResponseSkip) + handler.SetFanOutMerger(func(ctx context.Context, originalReq *http.Request, responses map[string][]byte) (*http.Response, error) { + // Should never be called since all responses are skipped + return MakeJSONResponse(http.StatusOK, map[string]interface{}{"ok": true}) + }) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +func TestMakeJSONResponse_StatusFormat(t *testing.T) { + // Verify the status string is formatted per net/http conventions (e.g. "200 OK") + resp, err := MakeJSONResponse(http.StatusOK, map[string]interface{}{"ok": true}) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, "200 OK", resp.Status) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + resp2, err := MakeJSONResponse(http.StatusNotFound, map[string]interface{}{"error": "not found"}) + require.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, "404 Not Found", resp2.Status) + assert.Equal(t, http.StatusNotFound, resp2.StatusCode) +} + +func TestCreateCompositeHandler_InvalidEmptyPolicy(t *testing.T) { + // createCompositeHandler should return an error for invalid empty_policy values + m := NewModule() + require.NotNil(t, m) + + cfg := &ReverseProxyConfig{ + DefaultBackend: "backend-a", + BackendServices: map[string]string{ + "backend-a": "http://localhost:9999", + "backend-b": "http://localhost:9998", + }, + CompositeRoutes: map[string]CompositeRoute{ + "/api/test": { + Pattern: "/api/test", + Backends: []string{"backend-a", "backend-b"}, + Strategy: "pipeline", + EmptyPolicy: "invalid-policy", + }, + }, + } + m.config = cfg + + route := cfg.CompositeRoutes["/api/test"] + _, err := m.createCompositeHandler(context.Background(), route, cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidEmptyResponsePolicy) +} diff --git a/modules/reverseproxy/composite_test.go b/modules/reverseproxy/composite_test.go index 671ac67e..3008082f 100644 --- a/modules/reverseproxy/composite_test.go +++ b/modules/reverseproxy/composite_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/config.go b/modules/reverseproxy/config.go index 532feaca..6c8c15c8 100644 --- a/modules/reverseproxy/config.go +++ b/modules/reverseproxy/config.go @@ -96,6 +96,11 @@ type CompositeRoute struct { Backends []string `json:"backends" yaml:"backends" toml:"backends" env:"BACKENDS"` Strategy string `json:"strategy" yaml:"strategy" toml:"strategy" env:"STRATEGY"` + // EmptyPolicy defines how empty backend responses are handled. + // Valid values: "allow-empty" (default), "skip-empty", "fail-on-empty". + // This is used by pipeline and fan-out-merge strategies. + EmptyPolicy string `json:"empty_policy" yaml:"empty_policy" toml:"empty_policy" env:"EMPTY_POLICY"` + // FeatureFlagID is the ID of the feature flag that controls whether this composite route is enabled // If specified and the feature flag evaluates to false, this route will return 404 FeatureFlagID string `json:"feature_flag_id" yaml:"feature_flag_id" toml:"feature_flag_id" env:"FEATURE_FLAG_ID"` diff --git a/modules/reverseproxy/config_overwrite_reproduction_test.go b/modules/reverseproxy/config_overwrite_reproduction_test.go index f662b4b9..93c961e0 100644 --- a/modules/reverseproxy/config_overwrite_reproduction_test.go +++ b/modules/reverseproxy/config_overwrite_reproduction_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/debug.go b/modules/reverseproxy/debug.go index b1d91a83..a874892f 100644 --- a/modules/reverseproxy/debug.go +++ b/modules/reverseproxy/debug.go @@ -6,7 +6,7 @@ import ( "reflect" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // DebugEndpointsConfig provides configuration for debug endpoints. diff --git a/modules/reverseproxy/debug_service_init_test.go b/modules/reverseproxy/debug_service_init_test.go index 9840d2f6..5bdbb74c 100644 --- a/modules/reverseproxy/debug_service_init_test.go +++ b/modules/reverseproxy/debug_service_init_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" ) diff --git a/modules/reverseproxy/dry_run_bug_fixes_test.go b/modules/reverseproxy/dry_run_bug_fixes_test.go index 8df9cd1b..c57efb63 100644 --- a/modules/reverseproxy/dry_run_bug_fixes_test.go +++ b/modules/reverseproxy/dry_run_bug_fixes_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDryRunBugFixes tests the specific bugs that were fixed in the dry-run feature: diff --git a/modules/reverseproxy/dry_run_issue_test.go b/modules/reverseproxy/dry_run_issue_test.go index 109c60f6..42ed5394 100644 --- a/modules/reverseproxy/dry_run_issue_test.go +++ b/modules/reverseproxy/dry_run_issue_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestDryRunIssue reproduces the exact issue described in the GitHub issue diff --git a/modules/reverseproxy/dryrun.go b/modules/reverseproxy/dryrun.go index 4ff9b33a..75816365 100644 --- a/modules/reverseproxy/dryrun.go +++ b/modules/reverseproxy/dryrun.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // DryRunConfig provides configuration for dry-run functionality. diff --git a/modules/reverseproxy/duration_support_test.go b/modules/reverseproxy/duration_support_test.go index f54a5efa..fefd793e 100644 --- a/modules/reverseproxy/duration_support_test.go +++ b/modules/reverseproxy/duration_support_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/errors.go b/modules/reverseproxy/errors.go index baec962c..e9c4b151 100644 --- a/modules/reverseproxy/errors.go +++ b/modules/reverseproxy/errors.go @@ -40,8 +40,9 @@ var ( ErrNoSubjectForEventEmission = errors.New("no subject available for event emission") // Dynamic operation errors - ErrBackendIDRequired = errors.New("backend id required") - ErrServiceURLRequired = errors.New("service URL required") - ErrNoBackendsConfigured = errors.New("no backends configured") - ErrBackendNotConfigured = errors.New("backend not configured") + ErrBackendIDRequired = errors.New("backend id required") + ErrServiceURLRequired = errors.New("service URL required") + ErrNoBackendsConfigured = errors.New("no backends configured") + ErrBackendNotConfigured = errors.New("backend not configured") + ErrInvalidEmptyResponsePolicy = errors.New("invalid empty_policy: must be one of allow-empty, skip-empty, fail-on-empty") ) diff --git a/modules/reverseproxy/external_evaluator_fallback_bug_test.go b/modules/reverseproxy/external_evaluator_fallback_bug_test.go index 1b3cc61b..ea58cffa 100644 --- a/modules/reverseproxy/external_evaluator_fallback_bug_test.go +++ b/modules/reverseproxy/external_evaluator_fallback_bug_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/mock" ) diff --git a/modules/reverseproxy/external_evaluator_fallback_integration_test.go b/modules/reverseproxy/external_evaluator_fallback_integration_test.go index 84ff1834..126098cf 100644 --- a/modules/reverseproxy/external_evaluator_fallback_integration_test.go +++ b/modules/reverseproxy/external_evaluator_fallback_integration_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/mock" ) diff --git a/modules/reverseproxy/feature_flag_aggregator_bdd_test.go b/modules/reverseproxy/feature_flag_aggregator_bdd_test.go index 96334961..fd3037bd 100644 --- a/modules/reverseproxy/feature_flag_aggregator_bdd_test.go +++ b/modules/reverseproxy/feature_flag_aggregator_bdd_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/cucumber/godog" ) diff --git a/modules/reverseproxy/feature_flag_aggregator_test.go b/modules/reverseproxy/feature_flag_aggregator_test.go index c0df5309..ef8a6bf3 100644 --- a/modules/reverseproxy/feature_flag_aggregator_test.go +++ b/modules/reverseproxy/feature_flag_aggregator_test.go @@ -9,7 +9,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Mock evaluators for testing diff --git a/modules/reverseproxy/feature_flags.go b/modules/reverseproxy/feature_flags.go index 7275fa7e..c21a1cac 100644 --- a/modules/reverseproxy/feature_flags.go +++ b/modules/reverseproxy/feature_flags.go @@ -9,7 +9,7 @@ import ( "reflect" "sort" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // FeatureFlagEvaluator defines the interface for evaluating feature flags. diff --git a/modules/reverseproxy/feature_flags_test.go b/modules/reverseproxy/feature_flags_test.go index a29562fb..1e4cf2c7 100644 --- a/modules/reverseproxy/feature_flags_test.go +++ b/modules/reverseproxy/feature_flags_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestFileBasedFeatureFlagEvaluator_WithMockApp tests the feature flag evaluator with a mock application diff --git a/modules/reverseproxy/features/composite_pipeline.feature b/modules/reverseproxy/features/composite_pipeline.feature new file mode 100644 index 00000000..15565ae1 --- /dev/null +++ b/modules/reverseproxy/features/composite_pipeline.feature @@ -0,0 +1,37 @@ +Feature: Pipeline and Fan-Out-Merge Composite Strategies + As a developer building a multi-backend application + I want to chain backend requests and merge responses by ID + So that I can aggregate data from multiple services into unified responses + + Background: + Given I have a modular application with reverse proxy module configured + + Scenario: Pipeline strategy chains requests through multiple backends + Given I have a pipeline composite route with two backends + When I send a request to the pipeline route + Then the first backend should be called with the original request + And the second backend should receive data derived from the first response + And the final response should contain merged data from all stages + + Scenario: Fan-out-merge strategy merges responses by ID + Given I have a fan-out-merge composite route with two backends + When I send a request to the fan-out-merge route + Then both backends should be called in parallel + And the responses should be merged by matching IDs + And items with matching ancillary data should be enriched + + Scenario: Pipeline with empty response using skip policy + Given I have a pipeline route with skip-empty policy + When I send a request and a backend returns an empty response + Then the empty response should be excluded from the result + And the non-empty responses should still be present + + Scenario: Fan-out-merge with empty response using fail policy + Given I have a fan-out-merge route with fail-on-empty policy + When I send a request and a backend returns an empty response + Then the request should fail with a bad gateway error + + Scenario: Pipeline filters results using ancillary data + Given I have a pipeline route that filters by ancillary backend data + When I send a request to fetch filtered results + Then only items matching the ancillary criteria should be returned diff --git a/modules/reverseproxy/file_based_tenant_test.go b/modules/reverseproxy/file_based_tenant_test.go index 3f3a44dd..c3efa8ad 100644 --- a/modules/reverseproxy/file_based_tenant_test.go +++ b/modules/reverseproxy/file_based_tenant_test.go @@ -10,7 +10,7 @@ import ( "regexp" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/go.mod b/modules/reverseproxy/go.mod index b8175e8b..3ddd5828 100644 --- a/modules/reverseproxy/go.mod +++ b/modules/reverseproxy/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/reverseproxy/v2 +module github.com/GoCodeAlone/modular/modules/reverseproxy/v2 -go 1.25 +go 1.26 // retract (from old module path) v1.0.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/go-chi/chi/v5 v5.2.2 diff --git a/modules/reverseproxy/go.sum b/modules/reverseproxy/go.sum index 0438e88a..b90f6f2b 100644 --- a/modules/reverseproxy/go.sum +++ b/modules/reverseproxy/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/reverseproxy/health_endpoint_test.go b/modules/reverseproxy/health_endpoint_test.go index 78ae78e1..b040084a 100644 --- a/modules/reverseproxy/health_endpoint_test.go +++ b/modules/reverseproxy/health_endpoint_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestHealthEndpointNotProxied tests that health endpoints are not proxied to backends diff --git a/modules/reverseproxy/hostname_forwarding_test.go b/modules/reverseproxy/hostname_forwarding_test.go index ba07a448..7e0c71b9 100644 --- a/modules/reverseproxy/hostname_forwarding_test.go +++ b/modules/reverseproxy/hostname_forwarding_test.go @@ -9,7 +9,7 @@ import ( "net/url" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/integration_test.go b/modules/reverseproxy/integration_test.go index 5633f593..361b2a92 100644 --- a/modules/reverseproxy/integration_test.go +++ b/modules/reverseproxy/integration_test.go @@ -10,7 +10,7 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Integration tests for the complete feature flag aggregator system diff --git a/modules/reverseproxy/mock_test.go b/modules/reverseproxy/mock_test.go index 5418999a..c78d6003 100644 --- a/modules/reverseproxy/mock_test.go +++ b/modules/reverseproxy/mock_test.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" // Import chi for router type assertion ) diff --git a/modules/reverseproxy/mocks_for_test.go b/modules/reverseproxy/mocks_for_test.go index 590dab5d..44641f77 100644 --- a/modules/reverseproxy/mocks_for_test.go +++ b/modules/reverseproxy/mocks_for_test.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/mock" ) diff --git a/modules/reverseproxy/module.go b/modules/reverseproxy/module.go index 61c1cbac..25d1c2ff 100644 --- a/modules/reverseproxy/module.go +++ b/modules/reverseproxy/module.go @@ -23,7 +23,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/gobwas/glob" ) @@ -73,6 +73,15 @@ type ReverseProxyModule struct { // Response transformers for composite routes (keyed by route pattern) responseTransformers map[string]ResponseTransformer + // Pipeline configurations for composite routes (keyed by route pattern) + pipelineConfigs map[string]*PipelineConfig + + // Fan-out merger functions for composite routes (keyed by route pattern) + fanOutMergers map[string]FanOutMerger + + // Empty response policies for composite routes (keyed by route pattern) + emptyResponsePolicies map[string]EmptyResponsePolicy + // Metrics collection metrics *MetricsCollector enableMetrics bool @@ -170,17 +179,20 @@ func NewModule() *ReverseProxyModule { // either in Constructor (if httpclient service is available) // or in Init (with default settings) module := &ReverseProxyModule{ - httpClient: nil, - backendProxies: make(map[string]*httputil.ReverseProxy), - backendRoutes: make(map[string]map[string]http.HandlerFunc), - compositeRoutes: make(map[string]http.HandlerFunc), - tenants: make(map[modular.TenantID]*ReverseProxyConfig), - tenantBackendProxies: make(map[modular.TenantID]map[string]*httputil.ReverseProxy), - preProxyTransforms: make(map[string]func(*http.Request)), - circuitBreakers: make(map[string]*CircuitBreaker), - enableMetrics: true, - loadBalanceCounters: make(map[string]int), - responseTransformers: make(map[string]ResponseTransformer), + httpClient: nil, + backendProxies: make(map[string]*httputil.ReverseProxy), + backendRoutes: make(map[string]map[string]http.HandlerFunc), + compositeRoutes: make(map[string]http.HandlerFunc), + tenants: make(map[modular.TenantID]*ReverseProxyConfig), + tenantBackendProxies: make(map[modular.TenantID]map[string]*httputil.ReverseProxy), + preProxyTransforms: make(map[string]func(*http.Request)), + circuitBreakers: make(map[string]*CircuitBreaker), + enableMetrics: true, + loadBalanceCounters: make(map[string]int), + responseTransformers: make(map[string]ResponseTransformer), + pipelineConfigs: make(map[string]*PipelineConfig), + fanOutMergers: make(map[string]FanOutMerger), + emptyResponsePolicies: make(map[string]EmptyResponsePolicy), } return module @@ -1565,9 +1577,30 @@ func (m *ReverseProxyModule) SetResponseTransformer(pattern string, transformer m.responseTransformers[pattern] = transformer } +// SetPipelineConfig sets the pipeline configuration for a specific composite route pattern. +// This is required for routes using the "pipeline" strategy. +// The PipelineConfig includes a RequestBuilder (to construct each subsequent request +// from previous responses) and an optional ResponseMerger (to assemble the final response). +func (m *ReverseProxyModule) SetPipelineConfig(pattern string, config PipelineConfig) { + m.pipelineConfigs[pattern] = &config +} + +// SetFanOutMerger sets the fan-out merger function for a specific composite route pattern. +// This is required for routes using the "fan-out-merge" strategy. +// The merger receives all parallel backend response bodies and produces a unified response. +func (m *ReverseProxyModule) SetFanOutMerger(pattern string, merger FanOutMerger) { + m.fanOutMergers[pattern] = merger +} + +// SetEmptyResponsePolicy sets the empty response policy for a specific composite route pattern. +// This controls how empty backend responses are handled in pipeline and fan-out-merge strategies. +func (m *ReverseProxyModule) SetEmptyResponsePolicy(pattern string, policy EmptyResponsePolicy) { + m.emptyResponsePolicies[pattern] = policy +} + // createReverseProxyForBackend creates a reverse proxy for a specific backend with per-backend configuration. func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, target *url.URL, backendID string, endpoint string) *httputil.ReverseProxy { - proxy := httputil.NewSingleHostReverseProxy(target) + proxy := &httputil.ReverseProxy{} // Emit proxy created event m.emitEvent(ctx, EventTypeProxyCreated, map[string]interface{}{ @@ -1598,13 +1631,16 @@ func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, t // Store the original target for use in the director function originalTarget := *target - // Create a custom director that handles hostname forwarding and path rewriting - proxy.Director = func(req *http.Request) { + // Create a Rewrite function that handles hostname forwarding and path rewriting. + // This replaces the deprecated Director field (SA1019, deprecated since Go 1.26). + proxy.Rewrite = func(pr *httputil.ProxyRequest) { + req := pr.Out + // Extract tenant ID from the request header if available var tenantIDStr string var hasTenant bool if m.config != nil { - tenantIDStr, hasTenant = TenantIDFromRequest(m.config.TenantIDHeader, req) + tenantIDStr, hasTenant = TenantIDFromRequest(m.config.TenantIDHeader, pr.In) } // Get the appropriate configuration (tenant-specific or global) @@ -1621,7 +1657,7 @@ func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, t } // Apply path rewriting if configured - rewrittenPath := m.applyPathRewritingForBackend(req.URL.Path, config, backendID, endpoint) + rewrittenPath := m.applyPathRewritingForBackend(pr.In.URL.Path, config, backendID, endpoint) // Set up the request URL req.URL.Scheme = originalTarget.Scheme @@ -1637,25 +1673,13 @@ func (m *ReverseProxyModule) createReverseProxyForBackend(ctx context.Context, t // Apply header rewriting m.applyHeaderRewritingForBackend(req, config, backendID, endpoint, &originalTarget) - } - - // If a custom director factory is available, use it (this is for advanced use cases) - if m.directorFactory != nil { - // Get the backend ID from the target URL host - backend := originalTarget.Host - originalDirector := proxy.Director - // Create a custom director that handles the backend routing - proxy.Director = func(req *http.Request) { - // Apply our standard director first - originalDirector(req) + // Preserve X-Forwarded-* headers + pr.SetXForwarded() - // Then apply custom director if available - var tenantIDStr string - var hasTenant bool - if m.config != nil { - tenantIDStr, hasTenant = TenantIDFromRequest(m.config.TenantIDHeader, req) - } + // If a custom director factory is available, apply it + if m.directorFactory != nil { + backend := originalTarget.Host if hasTenant { tenantID := modular.TenantID(tenantIDStr) @@ -2532,7 +2556,8 @@ func (m *ReverseProxyModule) createBackendProxyHandler(backend string) http.Hand // Create a copy of the proxy with the timeout transport proxyCopy := &httputil.ReverseProxy{ - Director: proxy.Director, + Director: proxy.Director, //nolint:staticcheck // SA1019: preserve Director for backwards compatibility with legacy proxy creation + Rewrite: proxy.Rewrite, Transport: timeoutTransport, FlushInterval: proxy.FlushInterval, ErrorLog: proxy.ErrorLog, @@ -2728,7 +2753,8 @@ func (m *ReverseProxyModule) createBackendProxyHandler(backend string) http.Hand // No circuit breaker, use the proxy directly but capture status and apply timeout // Create a request-specific proxy to avoid race conditions on shared Transport field proxyForRequest := &httputil.ReverseProxy{ - Director: proxy.Director, + Director: proxy.Director, //nolint:staticcheck // SA1019: preserve Director for backwards compatibility with legacy proxy creation + Rewrite: proxy.Rewrite, Transport: proxy.Transport, // Start with the original transport FlushInterval: proxy.FlushInterval, ErrorLog: proxy.ErrorLog, @@ -2986,7 +3012,8 @@ func (m *ReverseProxyModule) createBackendProxyHandlerForTenant(tenantID modular // Create a copy of the proxy with the original transport proxyCopy := &httputil.ReverseProxy{ - Director: proxy.Director, + Director: proxy.Director, //nolint:staticcheck // SA1019: preserve Director for backwards compatibility with legacy proxy creation + Rewrite: proxy.Rewrite, Transport: originalTransport, FlushInterval: proxy.FlushInterval, ErrorLog: proxy.ErrorLog, diff --git a/modules/reverseproxy/module_test.go b/modules/reverseproxy/module_test.go index d517f36b..e78349fd 100644 --- a/modules/reverseproxy/module_test.go +++ b/modules/reverseproxy/module_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/new_features_test.go b/modules/reverseproxy/new_features_test.go index dfd28ec6..e7d3c8e9 100644 --- a/modules/reverseproxy/new_features_test.go +++ b/modules/reverseproxy/new_features_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestNewFeatures tests the newly added features for debug endpoints and dry-run functionality diff --git a/modules/reverseproxy/response_header_rewriting_test.go b/modules/reverseproxy/response_header_rewriting_test.go index 8cb130f8..1924aeb9 100644 --- a/modules/reverseproxy/response_header_rewriting_test.go +++ b/modules/reverseproxy/response_header_rewriting_test.go @@ -8,7 +8,7 @@ import ( "net/url" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/route_configs_test.go b/modules/reverseproxy/route_configs_test.go index e5881044..265516eb 100644 --- a/modules/reverseproxy/route_configs_test.go +++ b/modules/reverseproxy/route_configs_test.go @@ -8,7 +8,7 @@ import ( "os" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) func TestBasicRouteConfigsFeatureFlagRouting(t *testing.T) { diff --git a/modules/reverseproxy/routing_test.go b/modules/reverseproxy/routing_test.go index a7ce5236..ad817001 100644 --- a/modules/reverseproxy/routing_test.go +++ b/modules/reverseproxy/routing_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/service_dependency_test.go b/modules/reverseproxy/service_dependency_test.go index dae8c1ef..2cc0e8f7 100644 --- a/modules/reverseproxy/service_dependency_test.go +++ b/modules/reverseproxy/service_dependency_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/reverseproxy/service_exposure_test.go b/modules/reverseproxy/service_exposure_test.go index 76ed14f1..4a6cab05 100644 --- a/modules/reverseproxy/service_exposure_test.go +++ b/modules/reverseproxy/service_exposure_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestFeatureFlagEvaluatorServiceExposure tests that the module exposes the feature flag evaluator as a service diff --git a/modules/reverseproxy/tenant_backend_test.go b/modules/reverseproxy/tenant_backend_test.go index 57dd424d..7ce78978 100644 --- a/modules/reverseproxy/tenant_backend_test.go +++ b/modules/reverseproxy/tenant_backend_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_composite_test.go b/modules/reverseproxy/tenant_composite_test.go index 15112865..8bdbcd62 100644 --- a/modules/reverseproxy/tenant_composite_test.go +++ b/modules/reverseproxy/tenant_composite_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_config_override_test.go b/modules/reverseproxy/tenant_config_override_test.go index be931377..e5d7c5e3 100644 --- a/modules/reverseproxy/tenant_config_override_test.go +++ b/modules/reverseproxy/tenant_config_override_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_default_backend_test.go b/modules/reverseproxy/tenant_default_backend_test.go index 1a3a2590..5cfb9455 100644 --- a/modules/reverseproxy/tenant_default_backend_test.go +++ b/modules/reverseproxy/tenant_default_backend_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/tenant_header_enforcement_simple_test.go b/modules/reverseproxy/tenant_header_enforcement_simple_test.go index 60e5ff49..3fc5a512 100644 --- a/modules/reverseproxy/tenant_header_enforcement_simple_test.go +++ b/modules/reverseproxy/tenant_header_enforcement_simple_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // TestTenantHeaderEnforcementSimple tests tenant header enforcement across all route types diff --git a/modules/reverseproxy/tenant_timeout_test.go b/modules/reverseproxy/tenant_timeout_test.go index 6d800c74..c7c2cf4f 100644 --- a/modules/reverseproxy/tenant_timeout_test.go +++ b/modules/reverseproxy/tenant_timeout_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/modules/reverseproxy/v2_interfaces.go b/modules/reverseproxy/v2_interfaces.go new file mode 100644 index 00000000..a290c192 --- /dev/null +++ b/modules/reverseproxy/v2_interfaces.go @@ -0,0 +1,58 @@ +package reverseproxy + +import ( + "context" + + "github.com/GoCodeAlone/modular" +) + +// Compile-time assertions for v2 interfaces +var _ modular.MetricsProvider = (*ReverseProxyModule)(nil) +var _ modular.Drainable = (*ReverseProxyModule)(nil) + +// CollectMetrics implements modular.MetricsProvider. +// It aggregates metrics from the internal MetricsCollector into the standard ModuleMetrics format. +func (m *ReverseProxyModule) CollectMetrics(_ context.Context) modular.ModuleMetrics { + values := make(map[string]float64) + + // Always report backend count + m.backendProxiesMutex.RLock() + values["backend_count"] = float64(len(m.backendProxies)) + m.backendProxiesMutex.RUnlock() + + // If metrics are enabled and the collector exists, aggregate request/error totals + if m.enableMetrics && m.metrics != nil { + m.metrics.mu.RLock() + var totalRequests, totalErrors int + for _, count := range m.metrics.requestCounts { + totalRequests += count + } + for _, count := range m.metrics.errorCounts { + totalErrors += count + } + m.metrics.mu.RUnlock() + + values["total_requests"] = float64(totalRequests) + values["total_errors"] = float64(totalErrors) + } + + return modular.ModuleMetrics{ + Name: m.Name(), + Values: values, + } +} + +// PreStop implements modular.Drainable. +// It stops the health checker during the drain phase, before the full Stop() is called. +func (m *ReverseProxyModule) PreStop(ctx context.Context) error { + if m.app != nil && m.app.Logger() != nil { + m.app.Logger().Info("PreStop: draining reverseproxy module") + } + + // Stop health checker early so backends aren't flapped during drain + if m.healthChecker != nil { + m.healthChecker.Stop(ctx) + } + + return nil +} diff --git a/modules/reverseproxy/v2_interfaces_test.go b/modules/reverseproxy/v2_interfaces_test.go new file mode 100644 index 00000000..cdc184ee --- /dev/null +++ b/modules/reverseproxy/v2_interfaces_test.go @@ -0,0 +1,103 @@ +package reverseproxy + +import ( + "context" + "io" + "log/slog" + "net/http/httputil" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReverseProxyModule_CollectMetrics(t *testing.T) { + t.Run("metrics enabled with data", func(t *testing.T) { + module := NewModule() + module.enableMetrics = true + module.metrics = NewMetricsCollector() + module.backendProxies = map[string]*httputil.ReverseProxy{ + "api1": {}, + "api2": {}, + "api3": {}, + } + + // Simulate some recorded requests + module.metrics.RecordRequest("api1", time.Now().Add(-10*time.Millisecond), 200, nil) + module.metrics.RecordRequest("api1", time.Now().Add(-5*time.Millisecond), 200, nil) + module.metrics.RecordRequest("api2", time.Now().Add(-8*time.Millisecond), 500, assert.AnError) + + result := module.CollectMetrics(context.Background()) + + assert.Equal(t, "reverseproxy", result.Name) + require.NotNil(t, result.Values) + assert.Equal(t, float64(3), result.Values["backend_count"]) + assert.Equal(t, float64(3), result.Values["total_requests"]) + assert.Equal(t, float64(1), result.Values["total_errors"]) + }) + + t.Run("metrics disabled returns only backend_count", func(t *testing.T) { + module := NewModule() + module.enableMetrics = false + module.backendProxies = map[string]*httputil.ReverseProxy{ + "api1": {}, + } + + result := module.CollectMetrics(context.Background()) + + assert.Equal(t, "reverseproxy", result.Name) + require.NotNil(t, result.Values) + assert.Equal(t, float64(1), result.Values["backend_count"]) + _, hasRequests := result.Values["total_requests"] + assert.False(t, hasRequests, "should not have total_requests when metrics disabled") + _, hasErrors := result.Values["total_errors"] + assert.False(t, hasErrors, "should not have total_errors when metrics disabled") + }) + + t.Run("no backends returns zero backend_count", func(t *testing.T) { + module := NewModule() + module.enableMetrics = false + module.backendProxies = map[string]*httputil.ReverseProxy{} + + result := module.CollectMetrics(context.Background()) + + assert.Equal(t, float64(0), result.Values["backend_count"]) + }) + + t.Run("satisfies MetricsProvider interface", func(t *testing.T) { + var _ modular.MetricsProvider = (*ReverseProxyModule)(nil) + }) +} + +func TestReverseProxyModule_PreStop(t *testing.T) { + t.Run("stops health checker", func(t *testing.T) { + module := NewModule() + // Create a minimal health checker that we can verify gets stopped + hc := &HealthChecker{ + running: true, + stopChan: make(chan struct{}), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + module.healthChecker = hc + + err := module.PreStop(context.Background()) + + require.NoError(t, err) + assert.False(t, hc.running, "health checker should be stopped after PreStop") + }) + + t.Run("nil health checker does not panic", func(t *testing.T) { + module := NewModule() + module.healthChecker = nil + + err := module.PreStop(context.Background()) + + require.NoError(t, err) + }) + + t.Run("satisfies Drainable interface", func(t *testing.T) { + var _ modular.Drainable = (*ReverseProxyModule)(nil) + }) +} diff --git a/modules/scheduler/README.md b/modules/scheduler/README.md index 2d79a679..53c7f018 100644 --- a/modules/scheduler/README.md +++ b/modules/scheduler/README.md @@ -1,6 +1,6 @@ # Scheduler Module -[![Go Reference](https://pkg.go.dev/badge/github.com/CrisisTextLine/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/CrisisTextLine/modular/modules/scheduler) +[![Go Reference](https://pkg.go.dev/badge/github.com/GoCodeAlone/modular/modules/scheduler.svg)](https://pkg.go.dev/github.com/GoCodeAlone/modular/modules/scheduler) The Scheduler Module provides job scheduling capabilities for Modular applications. It supports one-time and recurring jobs using cron syntax with comprehensive job history tracking. @@ -17,8 +17,8 @@ The Scheduler Module provides job scheduling capabilities for Modular applicatio ```go import ( - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/modules/scheduler" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/modules/scheduler" ) // Register the scheduler module with your Modular application diff --git a/modules/scheduler/bdd_base_test.go b/modules/scheduler/bdd_base_test.go index 8416101d..1986e64f 100644 --- a/modules/scheduler/bdd_base_test.go +++ b/modules/scheduler/bdd_base_test.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) diff --git a/modules/scheduler/bdd_core_lifecycle_test.go b/modules/scheduler/bdd_core_lifecycle_test.go index 013f6e08..317ac3e8 100644 --- a/modules/scheduler/bdd_core_lifecycle_test.go +++ b/modules/scheduler/bdd_core_lifecycle_test.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Core module lifecycle step implementations diff --git a/modules/scheduler/bdd_events_test.go b/modules/scheduler/bdd_events_test.go index 45a2b4ac..b7275840 100644 --- a/modules/scheduler/bdd_events_test.go +++ b/modules/scheduler/bdd_events_test.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Event observation step implementations diff --git a/modules/scheduler/bdd_persistence_test.go b/modules/scheduler/bdd_persistence_test.go index 86c1f7a7..01cd8c09 100644 --- a/modules/scheduler/bdd_persistence_test.go +++ b/modules/scheduler/bdd_persistence_test.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" ) // Persistence and recovery step implementations diff --git a/modules/scheduler/go.mod b/modules/scheduler/go.mod index 22407f25..401e25fd 100644 --- a/modules/scheduler/go.mod +++ b/modules/scheduler/go.mod @@ -1,11 +1,11 @@ -module github.com/CrisisTextLine/modular/modules/scheduler +module github.com/GoCodeAlone/modular/modules/scheduler -go 1.25 +go 1.26 -toolchain go1.25.0 +toolchain go1.26.0 require ( - github.com/CrisisTextLine/modular v1.11.11 + github.com/GoCodeAlone/modular v1.12.2 github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/cucumber/godog v0.15.1 github.com/google/uuid v1.6.0 diff --git a/modules/scheduler/go.sum b/modules/scheduler/go.sum index 99493e25..b61f553c 100644 --- a/modules/scheduler/go.sum +++ b/modules/scheduler/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= -github.com/CrisisTextLine/modular v1.11.11/go.mod h1:l92kynq0nxfqLzPDAtzoGxaVkWqx2h1XP+Zh5qzRIdg= +github.com/GoCodeAlone/modular v1.12.2 h1:ckNtsBaTxaGwv0VKBXdAkpHWCdzWM7omgmRA4VntWQA= +github.com/GoCodeAlone/modular v1.12.2/go.mod h1:mKkOcJtHO/Xlkaeb7G6g0JgRYBwqTyAgQ9Vvs1NosNs= github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= diff --git a/modules/scheduler/module.go b/modules/scheduler/module.go index 8b577073..b75d33b7 100644 --- a/modules/scheduler/module.go +++ b/modules/scheduler/module.go @@ -63,7 +63,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" ) @@ -565,6 +565,74 @@ func (m *SchedulerModule) emitEvent(ctx context.Context, eventType string, data } } +// CollectMetrics implements the modular.MetricsProvider interface. +// It returns operational metrics for the scheduler module including running state, +// worker count, total job count, and pending job count. +func (m *SchedulerModule) CollectMetrics(ctx context.Context) modular.ModuleMetrics { + m.schedulerLock.Lock() + running := m.running + m.schedulerLock.Unlock() + + values := map[string]float64{ + "running": 0.0, + } + if running { + values["running"] = 1.0 + } + + if m.config != nil { + values["worker_count"] = float64(m.config.WorkerCount) + } + + if m.jobStore != nil { + jobs, err := m.jobStore.GetJobs() + if err == nil { + values["job_count"] = float64(len(jobs)) + pending := 0 + for _, j := range jobs { + if j.Status == JobStatusPending { + pending++ + } + } + values["pending_jobs"] = float64(pending) + } + } + + return modular.ModuleMetrics{ + Name: m.name, + Values: values, + } +} + +// PreStop implements the modular.Drainable interface. +// It persists jobs if persistence is enabled and signals the scheduler to stop +// dispatching new jobs. Actual worker shutdown happens in Stop(). +func (m *SchedulerModule) PreStop(ctx context.Context) error { + m.schedulerLock.Lock() + defer m.schedulerLock.Unlock() + + if m.logger != nil { + m.logger.Info("Scheduler drain phase starting") + } + + // Save jobs if persistence is enabled + if m.config != nil && m.config.PersistenceBackend != PersistenceBackendNone { + if err := m.savePersistedJobs(); err != nil { + if m.logger != nil { + m.logger.Warn("PreStop: failed to save jobs", "error", err) + } + } + } + + // Stop dispatching new jobs by cancelling the scheduler's context. + // Workers will finish in-flight jobs; Stop() handles the full shutdown. + if m.scheduler != nil && m.scheduler.cancel != nil { + m.scheduler.cancel() + } + + return nil +} + // GetRegisteredEventTypes implements the ObservableModule interface. // Returns all event types that this scheduler module can emit. func (m *SchedulerModule) GetRegisteredEventTypes() []string { diff --git a/modules/scheduler/module_test.go b/modules/scheduler/module_test.go index da0b9ea2..ea14f424 100644 --- a/modules/scheduler/module_test.go +++ b/modules/scheduler/module_test.go @@ -10,7 +10,7 @@ import ( "testing/synctest" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/scheduler/scheduler.go b/modules/scheduler/scheduler.go index cbe59209..21b69c98 100644 --- a/modules/scheduler/scheduler.go +++ b/modules/scheduler/scheduler.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/google/uuid" "github.com/robfig/cron/v3" @@ -194,13 +194,13 @@ func (s *Scheduler) Start(ctx context.Context) error { s.logger.Info("Starting scheduler", "workers", s.workerCount, "queueSize", s.queueSize) } - s.ctx, s.cancel = context.WithCancel(ctx) + s.ctx, s.cancel = context.WithCancel(ctx) //nolint:gosec // G118: cancel is stored in s.cancel and called in Stop() s.jobQueue = make(chan Job, s.queueSize) // Start worker goroutines for i := 0; i < s.workerCount; i++ { s.wg.Add(1) - //nolint:contextcheck // Context is passed through s.ctx field + //nolint:contextcheck,gosec // Context is passed through s.ctx field; workers use s.ctx go s.worker(i) // Emit worker started event diff --git a/modules/scheduler/test_persistence_handler_test.go b/modules/scheduler/test_persistence_handler_test.go index aa4f7233..0ba00b4f 100644 --- a/modules/scheduler/test_persistence_handler_test.go +++ b/modules/scheduler/test_persistence_handler_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/modular" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/modules/scheduler/v2_interfaces_test.go b/modules/scheduler/v2_interfaces_test.go new file mode 100644 index 00000000..33086366 --- /dev/null +++ b/modules/scheduler/v2_interfaces_test.go @@ -0,0 +1,135 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Compile-time interface checks +var ( + _ modular.MetricsProvider = (*SchedulerModule)(nil) + _ modular.Drainable = (*SchedulerModule)(nil) +) + +func TestSchedulerModule_CollectMetrics(t *testing.T) { + t.Run("not running, no jobs", func(t *testing.T) { + module := NewModule().(*SchedulerModule) + app := newMockApp() + module.RegisterConfig(app) + require.NoError(t, module.Init(app)) + + metrics := module.CollectMetrics(context.Background()) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 0.0, metrics.Values["running"]) + assert.Equal(t, float64(5), metrics.Values["worker_count"]) // default config + assert.Equal(t, 0.0, metrics.Values["job_count"]) + assert.Equal(t, 0.0, metrics.Values["pending_jobs"]) + }) + + t.Run("running with jobs", func(t *testing.T) { + module := NewModule().(*SchedulerModule) + app := newMockApp() + config := &SchedulerConfig{ + WorkerCount: 3, + QueueSize: 50, + StorageType: "memory", + CheckInterval: 1 * time.Second, + ShutdownTimeout: 5 * time.Second, + RetentionDays: 7, + PersistenceBackend: PersistenceBackendNone, + } + app.RegisterConfigSection(ModuleName, modular.NewStdConfigProvider(config)) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + defer module.Stop(ctx) //nolint:errcheck + + // Schedule a pending job (far future so it stays pending) + _, err := module.ScheduleJob(Job{ + Name: "metrics-test-pending", + RunAt: time.Now().Add(24 * time.Hour), + JobFunc: func(ctx context.Context) error { return nil }, + }) + require.NoError(t, err) + + // Schedule and cancel a job so we have mixed statuses + cancelID, err := module.ScheduleJob(Job{ + Name: "metrics-test-cancel", + RunAt: time.Now().Add(24 * time.Hour), + JobFunc: func(ctx context.Context) error { return nil }, + }) + require.NoError(t, err) + require.NoError(t, module.CancelJob(cancelID)) + + metrics := module.CollectMetrics(ctx) + assert.Equal(t, ModuleName, metrics.Name) + assert.Equal(t, 1.0, metrics.Values["running"]) + assert.Equal(t, float64(3), metrics.Values["worker_count"]) + assert.Equal(t, 2.0, metrics.Values["job_count"]) + assert.Equal(t, 1.0, metrics.Values["pending_jobs"]) + }) +} + +func TestSchedulerModule_PreStop(t *testing.T) { + t.Run("persists jobs on drain", func(t *testing.T) { + handler := NewMemoryPersistenceHandler() + module := NewModule().(*SchedulerModule) + app := newMockApp() + config := &SchedulerConfig{ + WorkerCount: 2, + QueueSize: 10, + StorageType: "memory", + CheckInterval: 1 * time.Second, + ShutdownTimeout: 5 * time.Second, + RetentionDays: 7, + PersistenceBackend: PersistenceBackendMemory, + PersistenceHandler: handler, + } + app.RegisterConfigSection(ModuleName, modular.NewStdConfigProvider(config)) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + + // Schedule a future job + _, err := module.ScheduleJob(Job{ + Name: "prestop-test", + RunAt: time.Now().Add(24 * time.Hour), + JobFunc: func(ctx context.Context) error { return nil }, + }) + require.NoError(t, err) + + // Call PreStop — should save jobs and cancel dispatcher + err = module.PreStop(ctx) + require.NoError(t, err) + + // Verify persistence handler has data + data := handler.GetStoredData() + assert.NotEmpty(t, data, "PreStop should have persisted jobs") + + // Clean up — Stop() should still succeed even after PreStop cancelled the context + _ = module.Stop(ctx) + }) + + t.Run("no persistence configured", func(t *testing.T) { + module := NewModule().(*SchedulerModule) + app := newMockApp() + module.RegisterConfig(app) + require.NoError(t, module.Init(app)) + + ctx := context.Background() + require.NoError(t, module.Start(ctx)) + + // PreStop with no persistence should succeed (no-op for persistence) + err := module.PreStop(ctx) + require.NoError(t, err) + + _ = module.Stop(ctx) + }) +} diff --git a/nil_interface_panic_test.go b/nil_interface_panic_test.go index 51d84e70..00e07325 100644 --- a/nil_interface_panic_test.go +++ b/nil_interface_panic_test.go @@ -35,14 +35,14 @@ func TestTypeImplementsInterfaceWithNil(t *testing.T) { app := &StdApplication{} // Test with nil svcType (should not panic) - interfaceType := reflect.TypeOf((*NilTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[NilTestInterface]() result := app.typeImplementsInterface(nil, interfaceType) if result { t.Error("Expected false when svcType is nil") } // Test with nil interfaceType (should not panic) - svcType := reflect.TypeOf("") + svcType := reflect.TypeFor[string]() result = app.typeImplementsInterface(svcType, nil) if result { t.Error("Expected false when interfaceType is nil") @@ -68,7 +68,7 @@ func TestGetServicesByInterfaceWithNilService(t *testing.T) { } // This should not panic - interfaceType := reflect.TypeOf((*NilTestInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[NilTestInterface]() results := app.GetServicesByInterface(interfaceType) // Should return empty results, not panic @@ -117,7 +117,7 @@ func (m *interfaceConsumerModule) RequiresServices() []ServiceDependency { return []ServiceDependency{{ Name: "testService", MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*NilTestInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[NilTestInterface](), Required: false, // Make it optional to avoid required service errors }} } diff --git a/observer.go b/observer.go index 5077919b..a8739b66 100644 --- a/observer.go +++ b/observer.go @@ -90,6 +90,22 @@ const ( EventTypeApplicationStarted = "com.modular.application.started" EventTypeApplicationStopped = "com.modular.application.stopped" EventTypeApplicationFailed = "com.modular.application.failed" + + // Tenant guard events + EventTypeTenantViolation = "com.modular.tenant.violation" + + // Configuration reload events + EventTypeConfigReloadStarted = "com.modular.config.reload.started" + EventTypeConfigReloadCompleted = "com.modular.config.reload.completed" + EventTypeConfigReloadFailed = "com.modular.config.reload.failed" + EventTypeConfigReloadNoop = "com.modular.config.reload.noop" + + // Health events + EventTypeHealthEvaluated = "com.modular.health.evaluated" + EventTypeHealthStatusChanged = "com.modular.health.status.changed" + + // Phase events + EventTypeAppPhaseChanged = "com.modular.application.phase.changed" ) // ObservableModule is an optional interface that modules can implement diff --git a/observer_cloudevents.go b/observer_cloudevents.go index 08dd5c30..67115d24 100644 --- a/observer_cloudevents.go +++ b/observer_cloudevents.go @@ -17,7 +17,7 @@ type CloudEvent = cloudevents.Event // NewCloudEvent creates a new CloudEvent with the specified parameters. // This is a convenience function for creating properly formatted CloudEvents. -func NewCloudEvent(eventType, source string, data interface{}, metadata map[string]interface{}) cloudevents.Event { +func NewCloudEvent(eventType, source string, data any, metadata map[string]any) cloudevents.Event { event := cloudevents.NewEvent() // Set required attributes @@ -58,12 +58,12 @@ type ModuleLifecyclePayload struct { // Timestamp is when the lifecycle action occurred (RFC3339 in JSON output). Timestamp time.Time `json:"timestamp"` // Additional arbitrary metadata (kept minimal; prefer evolving the struct if fields become first-class). - Metadata map[string]interface{} `json:"metadata,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } // NewModuleLifecycleEvent builds a CloudEvent for a module/application lifecycle using the structured payload. // It sets payload_schema and module_action extensions for lightweight routing without full payload decode. -func NewModuleLifecycleEvent(source, subject, name, version, action string, metadata map[string]interface{}) cloudevents.Event { +func NewModuleLifecycleEvent(source, subject, name, version, action string, metadata map[string]any) cloudevents.Event { payload := ModuleLifecyclePayload{ Subject: subject, Name: name, diff --git a/observer_cloudevents_test.go b/observer_cloudevents_test.go index 7c39321c..4a2218d9 100644 --- a/observer_cloudevents_test.go +++ b/observer_cloudevents_test.go @@ -13,14 +13,14 @@ import ( // Mock types for testing type mockConfigProvider struct { - config interface{} + config any } -func (m *mockConfigProvider) GetConfig() interface{} { +func (m *mockConfigProvider) GetConfig() any { return m.config } -func (m *mockConfigProvider) GetDefaultConfig() interface{} { +func (m *mockConfigProvider) GetDefaultConfig() any { return m.config } @@ -32,28 +32,28 @@ type mockLogger struct { type mockLogEntry struct { Level string Message string - Args []interface{} + Args []any } -func (l *mockLogger) Info(msg string, args ...interface{}) { +func (l *mockLogger) Info(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "INFO", Message: msg, Args: args}) } -func (l *mockLogger) Error(msg string, args ...interface{}) { +func (l *mockLogger) Error(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "ERROR", Message: msg, Args: args}) } -func (l *mockLogger) Debug(msg string, args ...interface{}) { +func (l *mockLogger) Debug(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "DEBUG", Message: msg, Args: args}) } -func (l *mockLogger) Warn(msg string, args ...interface{}) { +func (l *mockLogger) Warn(msg string, args ...any) { l.mu.Lock() defer l.mu.Unlock() l.entries = append(l.entries, mockLogEntry{Level: "WARN", Message: msg, Args: args}) @@ -72,8 +72,8 @@ func (m *mockModule) Init(app Application) error { } func TestNewCloudEvent(t *testing.T) { - data := map[string]interface{}{"test": "data"} - metadata := map[string]interface{}{"key": "value"} + data := map[string]any{"test": "data"} + metadata := map[string]any{"key": "value"} event := NewCloudEvent("test.event", "test.source", data, metadata) @@ -84,7 +84,7 @@ func TestNewCloudEvent(t *testing.T) { assert.False(t, event.Time().IsZero()) // Check data - var eventData map[string]interface{} + var eventData map[string]any err := event.DataAs(&eventData) require.NoError(t, err) assert.Equal(t, "data", eventData["test"]) diff --git a/observer_test.go b/observer_test.go index 5061ae69..775a210a 100644 --- a/observer_test.go +++ b/observer_test.go @@ -3,6 +3,7 @@ package modular import ( "context" "errors" + "slices" "testing" "time" @@ -11,7 +12,7 @@ import ( func TestCloudEvent(t *testing.T) { t.Parallel() - metadata := map[string]interface{}{"key": "value"} + metadata := map[string]any{"key": "value"} event := NewCloudEvent( "test.event", "test.source", @@ -195,11 +196,8 @@ func (m *mockSubject) NotifyObservers(ctx context.Context, event cloudevents.Eve _ = registration.observer.OnEvent(ctx, event) } else { // Check if event type matches observer's interests - for _, eventType := range registration.eventTypes { - if eventType == event.Type() { - _ = registration.observer.OnEvent(ctx, event) - break - } + if slices.Contains(registration.eventTypes, event.Type()) { + _ = registration.observer.OnEvent(ctx, event) } } } diff --git a/parallel_init_test.go b/parallel_init_test.go new file mode 100644 index 00000000..35fce24a --- /dev/null +++ b/parallel_init_test.go @@ -0,0 +1,112 @@ +package modular + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +type parallelInitModule struct { + name string + deps []string + initDelay time.Duration + initCount *atomic.Int32 + maxPar *atomic.Int32 + curPar *atomic.Int32 +} + +func (m *parallelInitModule) Name() string { return m.name } +func (m *parallelInitModule) Dependencies() []string { return m.deps } +func (m *parallelInitModule) Init(app Application) error { + cur := m.curPar.Add(1) + defer m.curPar.Add(-1) + for { + old := m.maxPar.Load() + if cur <= old || m.maxPar.CompareAndSwap(old, cur) { + break + } + } + m.initCount.Add(1) + time.Sleep(m.initDelay) + return nil +} + +func TestWithParallelInit_ConcurrentSameDepth(t *testing.T) { + var initCount, maxPar, curPar atomic.Int32 + modA := ¶llelInitModule{name: "a", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modB := ¶llelInitModule{name: "b", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + modC := ¶llelInitModule{name: "c", initDelay: 50 * time.Millisecond, initCount: &initCount, maxPar: &maxPar, curPar: &curPar} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modA, modB, modC), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + start := time.Now() + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + elapsed := time.Since(start) + + if initCount.Load() != 3 { + t.Errorf("expected 3 inits, got %d", initCount.Load()) + } + if maxPar.Load() < 2 { + t.Errorf("expected at least 2 concurrent inits, got max %d", maxPar.Load()) + } + if elapsed > 120*time.Millisecond { + t.Errorf("expected parallel init to be faster, took %v", elapsed) + } +} + +func TestWithParallelInit_RespectsDepOrder(t *testing.T) { + var mu sync.Mutex + order := make([]string, 0) + + makeModule := func(name string, deps []string) *simpleOrderModule { + return &simpleOrderModule{name: name, deps: deps, order: &order, mu: &mu} + } + + modDep := makeModule("dep", nil) + modA := makeModule("a", []string{"dep"}) + modB := makeModule("b", []string{"dep"}) + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(modDep, modA, modB), + WithParallelInit(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(order) != 3 || order[0] != "dep" { + t.Errorf("expected dep first, got order %v", order) + } +} + +type simpleOrderModule struct { + name string + deps []string + order *[]string + mu *sync.Mutex +} + +func (m *simpleOrderModule) Name() string { return m.name } +func (m *simpleOrderModule) Dependencies() []string { return m.deps } +func (m *simpleOrderModule) Init(app Application) error { + m.mu.Lock() + *m.order = append(*m.order, m.name) + m.mu.Unlock() + return nil +} diff --git a/phase.go b/phase.go new file mode 100644 index 00000000..9ddddeb9 --- /dev/null +++ b/phase.go @@ -0,0 +1,38 @@ +package modular + +// AppPhase represents the current lifecycle phase of the application. +type AppPhase int32 + +const ( + PhaseCreated AppPhase = iota + PhaseInitializing + PhaseInitialized + PhaseStarting + PhaseRunning + PhaseDraining + PhaseStopping + PhaseStopped +) + +func (p AppPhase) String() string { + switch p { + case PhaseCreated: + return "created" + case PhaseInitializing: + return "initializing" + case PhaseInitialized: + return "initialized" + case PhaseStarting: + return "starting" + case PhaseRunning: + return "running" + case PhaseDraining: + return "draining" + case PhaseStopping: + return "stopping" + case PhaseStopped: + return "stopped" + default: + return "unknown" + } +} diff --git a/phase_test.go b/phase_test.go new file mode 100644 index 00000000..145f9eee --- /dev/null +++ b/phase_test.go @@ -0,0 +1,60 @@ +package modular + +import ( + "testing" +) + +func TestAppPhase_String(t *testing.T) { + tests := []struct { + phase AppPhase + want string + }{ + {PhaseCreated, "created"}, + {PhaseInitializing, "initializing"}, + {PhaseInitialized, "initialized"}, + {PhaseStarting, "starting"}, + {PhaseRunning, "running"}, + {PhaseDraining, "draining"}, + {PhaseStopping, "stopping"}, + {PhaseStopped, "stopped"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("AppPhase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestPhaseTracking_LifecycleTransitions(t *testing.T) { + app, err := NewApplication(WithLogger(nopLogger{})) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + + stdApp := app.(*StdApplication) + + if stdApp.Phase() != PhaseCreated { + t.Errorf("expected PhaseCreated, got %v", stdApp.Phase()) + } + + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if stdApp.Phase() != PhaseInitialized { + t.Errorf("expected PhaseInitialized after Init, got %v", stdApp.Phase()) + } + + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if stdApp.Phase() != PhaseRunning { + t.Errorf("expected PhaseRunning after Start, got %v", stdApp.Phase()) + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if stdApp.Phase() != PhaseStopped { + t.Errorf("expected PhaseStopped after Stop, got %v", stdApp.Phase()) + } +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 00000000..0976dc85 --- /dev/null +++ b/plugin.go @@ -0,0 +1,25 @@ +package modular + +// Plugin is the minimal interface for a plugin bundle that provides modules. +type Plugin interface { + Name() string + Modules() []Module +} + +// PluginWithHooks extends Plugin with initialization hooks. +type PluginWithHooks interface { + Plugin + InitHooks() []func(Application) error +} + +// PluginWithServices extends Plugin with service definitions. +type PluginWithServices interface { + Plugin + Services() []ServiceDefinition +} + +// ServiceDefinition describes a service provided by a plugin. +type ServiceDefinition struct { + Name string + Service any +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 00000000..8c0af737 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,89 @@ +package modular + +import "testing" + +type testPlugin struct { + modules []Module + services []ServiceDefinition + hookRan bool +} + +func (p *testPlugin) Name() string { return "test-plugin" } +func (p *testPlugin) Modules() []Module { return p.modules } +func (p *testPlugin) Services() []ServiceDefinition { return p.services } +func (p *testPlugin) InitHooks() []func(Application) error { + return []func(Application) error{ + func(app Application) error { + p.hookRan = true + return nil + }, + } +} + +type pluginTestModule struct { + name string + initialized bool +} + +func (m *pluginTestModule) Name() string { return m.name } +func (m *pluginTestModule) Init(app Application) error { m.initialized = true; return nil } + +func TestWithPlugins_RegistersModulesAndServices(t *testing.T) { + mod := &pluginTestModule{name: "plugin-mod"} + plugin := &testPlugin{ + modules: []Module{mod}, + services: []ServiceDefinition{{Name: "plugin.svc", Service: "hello"}}, + } + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if !mod.initialized { + t.Error("plugin module should have been initialized") + } + if !plugin.hookRan { + t.Error("plugin hook should have run") + } + + svc, err := GetTypedService[string](app, "plugin.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if svc != "hello" { + t.Errorf("expected hello, got %s", svc) + } +} + +type simpleTestPlugin struct { + modules []Module +} + +func (p *simpleTestPlugin) Name() string { return "simple" } +func (p *simpleTestPlugin) Modules() []Module { return p.modules } + +func TestWithPlugins_SimplePlugin(t *testing.T) { + mod := &pluginTestModule{name: "simple-mod"} + plugin := &simpleTestPlugin{modules: []Module{mod}} + + app, err := NewApplication( + WithLogger(nopLogger{}), + WithPlugins(plugin), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if !mod.initialized { + t.Error("plugin module should have been initialized") + } +} diff --git a/reload.go b/reload.go new file mode 100644 index 00000000..bcfb1878 --- /dev/null +++ b/reload.go @@ -0,0 +1,167 @@ +package modular + +import ( + "fmt" + "strings" + "time" +) + +// ChangeType represents the type of configuration change. +type ChangeType int + +const ( + // ChangeAdded indicates a new configuration field was added. + ChangeAdded ChangeType = iota + // ChangeModified indicates an existing configuration field was modified. + ChangeModified + // ChangeRemoved indicates a configuration field was removed. + ChangeRemoved +) + +// String returns the string representation of a ChangeType. +func (ct ChangeType) String() string { + switch ct { + case ChangeAdded: + return "added" + case ChangeModified: + return "modified" + case ChangeRemoved: + return "removed" + default: + return "unknown" + } +} + +// ConfigChange represents a single configuration change detected during reload. +type ConfigChange struct { + Section string + FieldPath string + OldValue string + NewValue string + Source string +} + +// FieldChange represents a detailed field-level change with validation metadata. +type FieldChange struct { + OldValue any + NewValue any + FieldPath string + ChangeType ChangeType + IsSensitive bool + ValidationResult error +} + +// ConfigDiff represents the complete set of configuration changes between two states. +type ConfigDiff struct { + Changed map[string]FieldChange + Added map[string]FieldChange + Removed map[string]FieldChange + Timestamp time.Time + DiffID string +} + +// HasChanges reports whether the diff contains any changes. +func (d ConfigDiff) HasChanges() bool { + return len(d.Changed) > 0 || len(d.Added) > 0 || len(d.Removed) > 0 +} + +// FilterByPrefix returns a new ConfigDiff containing only changes whose field paths +// start with the given prefix. +func (d ConfigDiff) FilterByPrefix(prefix string) ConfigDiff { + filtered := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + Timestamp: d.Timestamp, + DiffID: d.DiffID, + } + for k, v := range d.Changed { + if strings.HasPrefix(k, prefix) { + filtered.Changed[k] = v + } + } + for k, v := range d.Added { + if strings.HasPrefix(k, prefix) { + filtered.Added[k] = v + } + } + for k, v := range d.Removed { + if strings.HasPrefix(k, prefix) { + filtered.Removed[k] = v + } + } + return filtered +} + +// RedactSensitiveFields returns a copy of the diff with sensitive field values replaced +// by a redaction placeholder. +func (d ConfigDiff) RedactSensitiveFields() ConfigDiff { + redacted := ConfigDiff{ + Changed: make(map[string]FieldChange, len(d.Changed)), + Added: make(map[string]FieldChange, len(d.Added)), + Removed: make(map[string]FieldChange, len(d.Removed)), + Timestamp: d.Timestamp, + DiffID: d.DiffID, + } + redactMap := func(src map[string]FieldChange, dst map[string]FieldChange) { + for k, v := range src { + if v.IsSensitive { + v.OldValue = "[REDACTED]" + v.NewValue = "[REDACTED]" + } + dst[k] = v + } + } + redactMap(d.Changed, redacted.Changed) + redactMap(d.Added, redacted.Added) + redactMap(d.Removed, redacted.Removed) + return redacted +} + +// ChangeSummary returns a human-readable summary of all changes in the diff. +func (d ConfigDiff) ChangeSummary() string { + if !d.HasChanges() { + return "no changes" + } + var parts []string + if n := len(d.Added); n > 0 { + parts = append(parts, fmt.Sprintf("%d added", n)) + } + if n := len(d.Changed); n > 0 { + parts = append(parts, fmt.Sprintf("%d modified", n)) + } + if n := len(d.Removed); n > 0 { + parts = append(parts, fmt.Sprintf("%d removed", n)) + } + return strings.Join(parts, ", ") +} + +// ReloadTrigger indicates what initiated a configuration reload. +type ReloadTrigger int + +const ( + // ReloadManual indicates a reload triggered by an explicit API or CLI call. + ReloadManual ReloadTrigger = iota + // ReloadFileChange indicates a reload triggered by a file system change. + ReloadFileChange + // ReloadAPIRequest indicates a reload triggered by an API request. + ReloadAPIRequest + // ReloadScheduled indicates a reload triggered by a periodic schedule. + ReloadScheduled +) + +// String returns the string representation of a ReloadTrigger. +func (rt ReloadTrigger) String() string { + switch rt { + case ReloadManual: + return "manual" + case ReloadFileChange: + return "file_change" + case ReloadAPIRequest: + return "api_request" + case ReloadScheduled: + return "scheduled" + default: + return "unknown" + } +} diff --git a/reload_contract_bdd_test.go b/reload_contract_bdd_test.go new file mode 100644 index 00000000..6e7aa4e1 --- /dev/null +++ b/reload_contract_bdd_test.go @@ -0,0 +1,479 @@ +package modular + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/cucumber/godog" +) + +// Static errors for reload contract BDD tests. +var ( + errExpectedModuleReceiveChanges = errors.New("expected module to receive changes") + errExpectedCompletedEvent = errors.New("expected reload completed event") + errExpectedFailedEvent = errors.New("expected reload failed event") + errExpectedNoopEvent = errors.New("expected reload noop event") + errExpectedModuleSkipped = errors.New("expected non-reloadable module to be skipped") + errExpectedOtherModulesReloaded = errors.New("expected other modules to still be reloaded") + errExpectedRollback = errors.New("expected first module to be rolled back") + errExpectedCircuitBreakerReject = errors.New("expected circuit breaker to reject request") + errExpectedCircuitBreakerReset = errors.New("expected circuit breaker to eventually reset") + errExpectedNoModuleCalls = errors.New("expected no modules to be called") + errExpectedRequestsProcessed = errors.New("expected all requests to be processed") +) + +// reloadBDDMockReloadable is a mock Reloadable for BDD reload contract tests. +type reloadBDDMockReloadable struct { + name string + canReload bool + timeout time.Duration + reloadErr error + reloadCalls atomic.Int32 + lastChanges []ConfigChange + mu sync.Mutex +} + +func (m *reloadBDDMockReloadable) Reload(_ context.Context, changes []ConfigChange) error { + m.reloadCalls.Add(1) + m.mu.Lock() + m.lastChanges = changes + m.mu.Unlock() + return m.reloadErr +} + +func (m *reloadBDDMockReloadable) CanReload() bool { return m.canReload } +func (m *reloadBDDMockReloadable) ReloadTimeout() time.Duration { return m.timeout } + +// reloadBDDSubject captures events for BDD reload contract tests. +type reloadBDDSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *reloadBDDSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *reloadBDDSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *reloadBDDSubject) GetObservers() []ObserverInfo { return nil } +func (s *reloadBDDSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} + +func (s *reloadBDDSubject) eventTypes() []string { + s.mu.Lock() + defer s.mu.Unlock() + var types []string + for _, e := range s.events { + types = append(types, e.Type()) + } + return types +} + +func (s *reloadBDDSubject) reset() { + s.mu.Lock() + s.events = nil + s.mu.Unlock() +} + +// reloadBDDLogger implements Logger for BDD reload contract tests. +type reloadBDDLogger struct{} + +func (l *reloadBDDLogger) Info(_ string, _ ...any) {} +func (l *reloadBDDLogger) Error(_ string, _ ...any) {} +func (l *reloadBDDLogger) Warn(_ string, _ ...any) {} +func (l *reloadBDDLogger) Debug(_ string, _ ...any) {} + +// bddWaitForEvent polls until the subject has recorded an event of the given type, +// or the timeout elapses. Returns true if the event was observed. +func bddWaitForEvent(subject *reloadBDDSubject, eventType string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if slices.Contains(subject.eventTypes(), eventType) { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} + +// bddWaitForCalls polls until the total reload calls across modules reaches +// at least n, or the timeout elapses. +func bddWaitForCalls(modules []*reloadBDDMockReloadable, n int32, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + var total int32 + for _, m := range modules { + total += m.reloadCalls.Load() + } + if total >= n { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} + +// ReloadBDDContext holds state for reload contract BDD scenarios. +type ReloadBDDContext struct { + orchestrator *ReloadOrchestrator + modules []*reloadBDDMockReloadable + subject *reloadBDDSubject + logger *reloadBDDLogger + ctx context.Context + cancel context.CancelFunc + reloadErr error + raceDetected atomic.Bool +} + +func (rc *ReloadBDDContext) reset() { + if rc.cancel != nil { + rc.cancel() + } + rc.subject = &reloadBDDSubject{} + rc.logger = &reloadBDDLogger{} + rc.modules = nil + rc.reloadErr = nil + rc.raceDetected.Store(false) + rc.ctx, rc.cancel = context.WithCancel(context.Background()) +} + +func (rc *ReloadBDDContext) newDiff() ConfigDiff { + return ConfigDiff{ + Changed: map[string]FieldChange{ + "db.host": {OldValue: "localhost", NewValue: "remotehost", ChangeType: ChangeModified}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + Timestamp: time.Now(), + DiffID: "bdd-test-diff", + } +} + +func (rc *ReloadBDDContext) emptyDiff() ConfigDiff { + return ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + DiffID: "bdd-empty-diff", + } +} + +// Step definitions + +func (rc *ReloadBDDContext) aReloadOrchestratorWithNReloadableModules(n int) error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + for i := range n { + mod := &reloadBDDMockReloadable{ + name: string(rune('a'+i)) + "_mod", + canReload: true, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, mod) + rc.orchestrator.RegisterReloadable(mod.name, mod) + } + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) aReloadIsRequestedWithConfigurationChanges() error { + diff := rc.newDiff() + rc.reloadErr = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + bddWaitForEvent(rc.subject, EventTypeConfigReloadCompleted, 2*time.Second) + return nil +} + +func (rc *ReloadBDDContext) allNModulesShouldReceiveTheChanges(n int) error { + received := 0 + for _, mod := range rc.modules { + if mod.reloadCalls.Load() > 0 { + received++ + } + } + if received != n { + return errExpectedModuleReceiveChanges + } + return nil +} + +func (rc *ReloadBDDContext) aReloadCompletedEventShouldBeEmitted() error { + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadCompleted) { + return nil + } + return errExpectedCompletedEvent +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWithAModuleThatCannotReload() error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + + disabledMod := &reloadBDDMockReloadable{ + name: "disabled_mod", + canReload: false, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, disabledMod) + rc.orchestrator.RegisterReloadable(disabledMod.name, disabledMod) + + enabledMod := &reloadBDDMockReloadable{ + name: "enabled_mod", + canReload: true, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, enabledMod) + rc.orchestrator.RegisterReloadable(enabledMod.name, enabledMod) + + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) aReloadIsRequested() error { + diff := rc.newDiff() + rc.reloadErr = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + // Wait for either completed or failed event (covers both success and failure scenarios). + bddWaitForEvent(rc.subject, EventTypeConfigReloadCompleted, 2*time.Second) + bddWaitForEvent(rc.subject, EventTypeConfigReloadFailed, 100*time.Millisecond) + return nil +} + +func (rc *ReloadBDDContext) theNonReloadableModuleShouldBeSkipped() error { + for _, mod := range rc.modules { + if !mod.canReload && mod.reloadCalls.Load() != 0 { + return errExpectedModuleSkipped + } + } + return nil +} + +func (rc *ReloadBDDContext) otherModulesShouldStillBeReloaded() error { + for _, mod := range rc.modules { + if mod.canReload && mod.reloadCalls.Load() == 0 { + return errExpectedOtherModulesReloaded + } + } + return nil +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWith3ModulesWhereTheSecondFails() error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + + // Use names that sort deterministically to control ordering. + mod1 := &reloadBDDMockReloadable{ + name: "aaa_first", + canReload: true, + timeout: 5 * time.Second, + } + mod2 := &reloadBDDMockReloadable{ + name: "bbb_second", + canReload: true, + timeout: 5 * time.Second, + reloadErr: errors.New("reload failure"), + } + mod3 := &reloadBDDMockReloadable{ + name: "ccc_third", + canReload: true, + timeout: 5 * time.Second, + } + rc.modules = append(rc.modules, mod1, mod2, mod3) + rc.orchestrator.RegisterReloadable(mod1.name, mod1) + rc.orchestrator.RegisterReloadable(mod2.name, mod2) + rc.orchestrator.RegisterReloadable(mod3.name, mod3) + + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) theFirstModuleShouldBeRolledBack() error { + // Reload targets are sorted by name. aaa_first runs before bbb_second (which + // fails), so aaa_first is always applied and then rolled back (2 calls total). + mod1 := rc.modules[0] + calls := mod1.reloadCalls.Load() + if calls != 2 { + return fmt.Errorf("%w: expected aaa_first to be called 2 times (apply + rollback), got %d", errExpectedRollback, calls) + } + return nil +} + +func (rc *ReloadBDDContext) aReloadFailedEventShouldBeEmitted() error { + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadFailed) { + return nil + } + return errExpectedFailedEvent +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWithAFailingModule() error { + rc.orchestrator = NewReloadOrchestrator(rc.logger, rc.subject) + + mod := &reloadBDDMockReloadable{ + name: "failing_mod", + canReload: true, + timeout: 5 * time.Second, + reloadErr: errors.New("always fails"), + } + rc.modules = append(rc.modules, mod) + rc.orchestrator.RegisterReloadable(mod.name, mod) + + rc.orchestrator.Start(rc.ctx) + return nil +} + +func (rc *ReloadBDDContext) nConsecutiveReloadsFail(n int) error { + diff := rc.newDiff() + for i := range n { + _ = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + expected := int32(i + 1) + bddWaitForCalls(rc.modules, expected, 2*time.Second) + } + return nil +} + +func (rc *ReloadBDDContext) subsequentReloadRequestsShouldBeRejected() error { + diff := rc.newDiff() + err := rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + if err == nil || !strings.Contains(err.Error(), "circuit breaker") { + return errExpectedCircuitBreakerReject + } + return nil +} + +func (rc *ReloadBDDContext) theCircuitBreakerShouldEventuallyReset() error { + // Simulate that the backoff period has elapsed by moving lastFailure + // sufficiently into the past. This validates isCircuitOpen()/backoffDuration() + // rather than bypassing them. + rc.orchestrator.cbMu.Lock() + rc.orchestrator.lastFailure = time.Now().Add(-circuitBreakerMaxDelay - time.Second) + rc.orchestrator.cbMu.Unlock() + + diff := rc.newDiff() + err := rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + if err != nil && strings.Contains(err.Error(), "circuit breaker") { + return errExpectedCircuitBreakerReset + } + return nil +} + +func (rc *ReloadBDDContext) aReloadOrchestratorWithReloadableModules() error { + return rc.aReloadOrchestratorWithNReloadableModules(2) +} + +func (rc *ReloadBDDContext) aReloadIsRequestedWithNoChanges() error { + diff := rc.emptyDiff() + rc.reloadErr = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + bddWaitForEvent(rc.subject, EventTypeConfigReloadNoop, 2*time.Second) + return nil +} + +func (rc *ReloadBDDContext) aReloadNoopEventShouldBeEmitted() error { + if slices.Contains(rc.subject.eventTypes(), EventTypeConfigReloadNoop) { + return nil + } + return errExpectedNoopEvent +} + +func (rc *ReloadBDDContext) noModulesShouldBeCalled() error { + for _, mod := range rc.modules { + if mod.reloadCalls.Load() != 0 { + return errExpectedNoModuleCalls + } + } + return nil +} + +func (rc *ReloadBDDContext) tenReloadRequestsAreSubmittedConcurrently() error { + diff := rc.newDiff() + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + _ = rc.orchestrator.RequestReload(rc.ctx, ReloadManual, diff) + }) + } + wg.Wait() + bddWaitForCalls(rc.modules, 1, 2*time.Second) + return nil +} + +func (rc *ReloadBDDContext) allRequestsShouldBeProcessed() error { + totalCalls := int32(0) + for _, mod := range rc.modules { + totalCalls += mod.reloadCalls.Load() + } + if totalCalls < 1 { + return errExpectedRequestsProcessed + } + return nil +} + +func (rc *ReloadBDDContext) noRaceConditionsShouldOccur() error { + // The race detector (go test -race) validates this at runtime. + // If we got here without a panic, there are no races. + return nil +} + +// InitializeReloadContractScenario wires up all reload contract BDD steps. +func InitializeReloadContractScenario(ctx *godog.ScenarioContext) { + rc := &ReloadBDDContext{} + + ctx.Before(func(ctx context.Context, _ *godog.Scenario) (context.Context, error) { + rc.reset() + return ctx, nil + }) + + ctx.After(func(ctx context.Context, _ *godog.Scenario, _ error) (context.Context, error) { + if rc.cancel != nil { + rc.cancel() + } + return ctx, nil + }) + + ctx.Step(`^a reload orchestrator with (\d+) reloadable modules$`, rc.aReloadOrchestratorWithNReloadableModules) + ctx.Step(`^a reload is requested with configuration changes$`, rc.aReloadIsRequestedWithConfigurationChanges) + ctx.Step(`^all (\d+) modules should receive the changes$`, rc.allNModulesShouldReceiveTheChanges) + ctx.Step(`^a reload completed event should be emitted$`, rc.aReloadCompletedEventShouldBeEmitted) + + ctx.Step(`^a reload orchestrator with a module that cannot reload$`, rc.aReloadOrchestratorWithAModuleThatCannotReload) + ctx.Step(`^a reload is requested$`, rc.aReloadIsRequested) + ctx.Step(`^the non-reloadable module should be skipped$`, rc.theNonReloadableModuleShouldBeSkipped) + ctx.Step(`^other modules should still be reloaded$`, rc.otherModulesShouldStillBeReloaded) + + ctx.Step(`^a reload orchestrator with 3 modules where the second fails$`, rc.aReloadOrchestratorWith3ModulesWhereTheSecondFails) + ctx.Step(`^the first module should be rolled back$`, rc.theFirstModuleShouldBeRolledBack) + ctx.Step(`^a reload failed event should be emitted$`, rc.aReloadFailedEventShouldBeEmitted) + + ctx.Step(`^a reload orchestrator with a failing module$`, rc.aReloadOrchestratorWithAFailingModule) + ctx.Step(`^(\d+) consecutive reloads fail$`, rc.nConsecutiveReloadsFail) + ctx.Step(`^subsequent reload requests should be rejected$`, rc.subsequentReloadRequestsShouldBeRejected) + ctx.Step(`^the circuit breaker should eventually reset$`, rc.theCircuitBreakerShouldEventuallyReset) + + ctx.Step(`^a reload orchestrator with reloadable modules$`, rc.aReloadOrchestratorWithReloadableModules) + ctx.Step(`^a reload is requested with no changes$`, rc.aReloadIsRequestedWithNoChanges) + ctx.Step(`^a reload noop event should be emitted$`, rc.aReloadNoopEventShouldBeEmitted) + ctx.Step(`^no modules should be called$`, rc.noModulesShouldBeCalled) + + ctx.Step(`^10 reload requests are submitted concurrently$`, rc.tenReloadRequestsAreSubmittedConcurrently) + ctx.Step(`^all requests should be processed$`, rc.allRequestsShouldBeProcessed) + ctx.Step(`^no race conditions should occur$`, rc.noRaceConditionsShouldOccur) +} + +// TestReloadContractBDD runs the BDD tests for the reload contract. +func TestReloadContractBDD(t *testing.T) { + suite := godog.TestSuite{ + ScenarioInitializer: InitializeReloadContractScenario, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features/reload_contract.feature"}, + TestingT: t, + Strict: true, + }, + } + + if suite.Run() != 0 { + t.Fatal("non-zero status returned, failed to run reload contract feature tests") + } +} diff --git a/reload_integration_test.go b/reload_integration_test.go new file mode 100644 index 00000000..f1cbac3d --- /dev/null +++ b/reload_integration_test.go @@ -0,0 +1,78 @@ +package modular + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type reloadableTestModule struct { + name string + reloadCount atomic.Int32 +} + +func (m *reloadableTestModule) Name() string { return m.name } +func (m *reloadableTestModule) Init(app Application) error { return nil } +func (m *reloadableTestModule) CanReload() bool { return true } +func (m *reloadableTestModule) ReloadTimeout() time.Duration { return 5 * time.Second } +func (m *reloadableTestModule) Reload(ctx context.Context, changes []ConfigChange) error { + m.reloadCount.Add(1) + return nil +} + +func TestWithDynamicReload_WiresOrchestrator(t *testing.T) { + mod := &reloadableTestModule{name: "hot-mod"} + app, err := NewApplication( + WithLogger(nopLogger{}), + WithModules(mod), + WithDynamicReload(), + ) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + stdApp := app.(*StdApplication) + diff := ConfigDiff{ + Changed: map[string]FieldChange{ + "key": {OldValue: "old", NewValue: "new", FieldPath: "key", ChangeType: ChangeModified}, + }, + DiffID: "test-diff", + } + if err := stdApp.RequestReload(context.Background(), ReloadManual, diff); err != nil { + t.Fatalf("RequestReload: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mod.reloadCount.Load() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if mod.reloadCount.Load() == 0 { + t.Error("expected module to be reloaded") + } + + if err := app.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} + +func TestRequestReload_WithoutDynamicReload(t *testing.T) { + app, err := NewApplication(WithLogger(nopLogger{})) + if err != nil { + t.Fatalf("NewApplication: %v", err) + } + stdApp := app.(*StdApplication) + err = stdApp.RequestReload(context.Background(), ReloadManual, ConfigDiff{}) + if err == nil { + t.Error("expected error when dynamic reload not enabled") + } +} diff --git a/reload_orchestrator.go b/reload_orchestrator.go new file mode 100644 index 00000000..3846b1c5 --- /dev/null +++ b/reload_orchestrator.go @@ -0,0 +1,393 @@ +package modular + +import ( + "context" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" +) + +// ReloadRequest represents a pending configuration reload request. +type ReloadRequest struct { + Trigger ReloadTrigger + Diff ConfigDiff + Ctx context.Context +} + +// reloadEntry pairs a module name with its Reloadable implementation. +type reloadEntry struct { + name string + module Reloadable +} + +// defaultReloadTimeout is used when a module returns a non-positive ReloadTimeout. +const defaultReloadTimeout = 30 * time.Second + +// ReloadOrchestrator coordinates configuration reloading across all registered +// Reloadable modules. It provides single-flight execution, circuit breaking, +// rollback on partial failure, and event emission via the observer pattern. +// +// Note: Application-level integration (Application.RequestReload(), WithDynamicReload() +// builder option) will be added when the Application interface is extended in a follow-up. +type ReloadOrchestrator struct { + mu sync.RWMutex + reloadables map[string]Reloadable + + requestCh chan ReloadRequest + stopped atomic.Bool + stopOnce sync.Once + + processing atomic.Bool + + // Circuit breaker state + cbMu sync.Mutex + failures int + lastFailure time.Time + circuitOpen bool + + logger Logger + subject Subject +} + +// nopLogger is a no-op Logger used when nil is passed. +type nopLogger struct{} + +func (nopLogger) Info(_ string, _ ...any) {} +func (nopLogger) Error(_ string, _ ...any) {} +func (nopLogger) Warn(_ string, _ ...any) {} +func (nopLogger) Debug(_ string, _ ...any) {} + +// NewReloadOrchestrator creates a new ReloadOrchestrator with the given logger and event subject. +// If logger is nil, a no-op logger is used. +func NewReloadOrchestrator(logger Logger, subject Subject) *ReloadOrchestrator { + if logger == nil { + logger = nopLogger{} + } + return &ReloadOrchestrator{ + reloadables: make(map[string]Reloadable), + requestCh: make(chan ReloadRequest, 100), + logger: logger, + subject: subject, + } +} + +// RegisterReloadable registers a named module as reloadable. +func (o *ReloadOrchestrator) RegisterReloadable(name string, module Reloadable) { + o.mu.Lock() + defer o.mu.Unlock() + o.reloadables[name] = module +} + +// RequestReload enqueues a reload request. It returns an error if the orchestrator +// is stopped, the request channel is full, or the circuit breaker is open. +// +// The method is safe to call concurrently with Stop(). A recover guard protects +// against the send-on-closed-channel panic that can occur when Stop() closes +// requestCh between the stopped check and the channel send. +func (o *ReloadOrchestrator) RequestReload(ctx context.Context, trigger ReloadTrigger, diff ConfigDiff) (retErr error) { + if o.stopped.Load() { + return ErrReloadStopped + } + if o.isCircuitOpen() { + return ErrReloadCircuitBreakerOpen + } + + // Recover from a send on closed channel if Stop() races between the + // stopped check above and the channel send below. + defer func() { + if r := recover(); r != nil { + retErr = ErrReloadStopped + } + }() + + select { + case o.requestCh <- ReloadRequest{Trigger: trigger, Diff: diff, Ctx: ctx}: + return nil + default: + return ErrReloadChannelFull + } +} + +// Start begins the background goroutine that drains the reload request queue. +func (o *ReloadOrchestrator) Start(ctx context.Context) { + go func() { + for { + select { + case <-ctx.Done(): + return + case req, ok := <-o.requestCh: + if !ok { + return + } + o.handleReload(ctx, req) + } + } + }() +} + +// handleReload derives a properly scoped context for a single reload request and +// processes it. The context is cancelled immediately after processReload returns +// to avoid resource leaks from accumulated timers in the processing loop. +// +// The reload context is rooted in parentCtx (the Start context) so that stopping +// the orchestrator always cancels in-flight work. When the request carries its +// own context, both its deadline and cancellation are wired in: deadline via +// context.WithDeadline, and cancellation via a background goroutine that watches +// req.Ctx.Done(). This ensures callers who cancel req.Ctx abort the reload. +func (o *ReloadOrchestrator) handleReload(parentCtx context.Context, req ReloadRequest) { + rctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + if req.Ctx != nil { + // Apply deadline if present. + if deadline, ok := req.Ctx.Deadline(); ok { + rctx, cancel = context.WithDeadline(rctx, deadline) //nolint:contextcheck // deadline from request + defer cancel() + } + + // Propagate cancellation from the request context. When req.Ctx is + // cancelled, cancel rctx so module Reload calls see cancellation. + go func() { + select { + case <-req.Ctx.Done(): + cancel() + case <-rctx.Done(): + // rctx already done (parent cancelled or reload finished); stop goroutine. + } + }() + } + + if err := o.processReload(rctx, req); err != nil { + o.logger.Error("Reload failed", "trigger", req.Trigger.String(), "error", err) + } +} + +// Stop signals the background goroutine to exit. It is safe to call multiple times. +func (o *ReloadOrchestrator) Stop() { + o.stopOnce.Do(func() { + o.stopped.Store(true) + close(o.requestCh) + }) +} + +// processReload executes a single reload request with atomic single-flight semantics, +// rollback on partial failure, and event emission. +func (o *ReloadOrchestrator) processReload(ctx context.Context, req ReloadRequest) error { + // Single-flight: only one reload at a time. + if !o.processing.CompareAndSwap(false, true) { + o.logger.Warn("Reload already in progress, skipping request") + return ErrReloadInProgress + } + defer o.processing.Store(false) + + // Noop if no changes — emit noop without a misleading "started" event. + if !req.Diff.HasChanges() { + o.emitEvent(ctx, EventTypeConfigReloadNoop, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + }) + return nil + } + + // Emit started event only when there are actual changes to apply. + o.emitEvent(ctx, EventTypeConfigReloadStarted, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + "summary": req.Diff.ChangeSummary(), + }) + + // Build the list of changes for the Reloadable interface. + changes := o.buildChanges(req.Diff) + + // Snapshot current reloadables under read lock, sorted by name for + // deterministic reload/rollback ordering. + o.mu.RLock() + targets := make([]reloadEntry, 0, len(o.reloadables)) + for name, mod := range o.reloadables { + targets = append(targets, reloadEntry{name: name, module: mod}) + } + o.mu.RUnlock() + + sort.Slice(targets, func(i, j int) bool { + return targets[i].name < targets[j].name + }) + + // Track which modules have been successfully reloaded (for rollback). + var applied []reloadEntry + + for _, t := range targets { + if !t.module.CanReload() { + o.logger.Info("Module cannot reload, skipping", "module", t.name) + continue + } + + timeout := t.module.ReloadTimeout() + if timeout <= 0 { + timeout = defaultReloadTimeout + } + rctx, cancel := context.WithTimeout(ctx, timeout) + + err := t.module.Reload(rctx, changes) + cancel() + + if err != nil { + o.logger.Error("Module reload failed, initiating rollback", + "module", t.name, "error", err) + + // Rollback already-applied modules in reverse order. + o.rollback(ctx, applied, changes) + + o.recordFailure() + o.emitEvent(ctx, EventTypeConfigReloadFailed, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + "failedModule": t.name, + "error": err.Error(), + }) + return fmt.Errorf("reload failed at module %s: %w", t.name, err) + } + + applied = append(applied, t) + } + + o.recordSuccess() + o.emitEvent(ctx, EventTypeConfigReloadCompleted, map[string]any{ + "trigger": req.Trigger.String(), + "diffId": req.Diff.DiffID, + "modulesLoaded": len(applied), + }) + return nil +} + +// buildChanges converts a ConfigDiff into a flat slice of ConfigChange entries. +func (o *ReloadOrchestrator) buildChanges(diff ConfigDiff) []ConfigChange { + var changes []ConfigChange + for path, fc := range diff.Added { + changes = append(changes, ConfigChange{ + FieldPath: path, + OldValue: fmt.Sprintf("%v", fc.OldValue), + NewValue: fmt.Sprintf("%v", fc.NewValue), + Source: "diff", + }) + } + for path, fc := range diff.Changed { + changes = append(changes, ConfigChange{ + FieldPath: path, + OldValue: fmt.Sprintf("%v", fc.OldValue), + NewValue: fmt.Sprintf("%v", fc.NewValue), + Source: "diff", + }) + } + for path, fc := range diff.Removed { + changes = append(changes, ConfigChange{ + FieldPath: path, + OldValue: fmt.Sprintf("%v", fc.OldValue), + NewValue: fmt.Sprintf("%v", fc.NewValue), + Source: "diff", + }) + } + return changes +} + +// rollback attempts to reverse already-applied changes on modules in reverse order. +// This is best-effort: errors are logged but not propagated. +func (o *ReloadOrchestrator) rollback(ctx context.Context, applied []reloadEntry, originalChanges []ConfigChange) { + // Build reverse changes (swap old and new values). + reverseChanges := make([]ConfigChange, len(originalChanges)) + for i, c := range originalChanges { + reverseChanges[i] = ConfigChange{ + Section: c.Section, + FieldPath: c.FieldPath, + OldValue: c.NewValue, + NewValue: c.OldValue, + Source: "rollback", + } + } + + // Apply in reverse order. + for i := len(applied) - 1; i >= 0; i-- { + t := applied[i] + timeout := t.module.ReloadTimeout() + if timeout <= 0 { + timeout = defaultReloadTimeout + } + rctx, cancel := context.WithTimeout(ctx, timeout) + + if err := t.module.Reload(rctx, reverseChanges); err != nil { + o.logger.Error("Rollback failed for module", "module", t.name, "error", err) + } else { + o.logger.Info("Rollback succeeded for module", "module", t.name) + } + cancel() + } +} + +// emitEvent sends a CloudEvent via the configured subject. +func (o *ReloadOrchestrator) emitEvent(ctx context.Context, eventType string, data map[string]any) { + if o.subject == nil { + return + } + event := NewCloudEvent(eventType, "modular.reload.orchestrator", data, nil) + if err := o.subject.NotifyObservers(ctx, event); err != nil { + o.logger.Debug("Failed to emit reload event", "eventType", eventType, "error", err) + } +} + +// Circuit breaker methods. + +const ( + circuitBreakerThreshold = 3 + circuitBreakerBaseDelay = 2 * time.Second + circuitBreakerMaxDelay = 2 * time.Minute +) + +func (o *ReloadOrchestrator) isCircuitOpen() bool { + o.cbMu.Lock() + defer o.cbMu.Unlock() + if !o.circuitOpen { + return false + } + // Check if the backoff period has elapsed. + if time.Since(o.lastFailure) > o.backoffDuration() { + o.circuitOpen = false + o.logger.Info("Reload circuit breaker reset after backoff") + return false + } + return true +} + +func (o *ReloadOrchestrator) recordSuccess() { + o.cbMu.Lock() + defer o.cbMu.Unlock() + o.failures = 0 + o.circuitOpen = false +} + +func (o *ReloadOrchestrator) recordFailure() { + o.cbMu.Lock() + defer o.cbMu.Unlock() + o.failures++ + o.lastFailure = time.Now() + if o.failures >= circuitBreakerThreshold { + o.circuitOpen = true + o.logger.Warn("Reload circuit breaker opened", + "failures", o.failures, + "backoff", o.backoffDuration().String()) + } +} + +func (o *ReloadOrchestrator) backoffDuration() time.Duration { + if o.failures <= 0 { + return circuitBreakerBaseDelay + } + d := circuitBreakerBaseDelay + for i := 1; i < o.failures; i++ { + d *= 2 + if d > circuitBreakerMaxDelay { + return circuitBreakerMaxDelay + } + } + return d +} diff --git a/reload_test.go b/reload_test.go new file mode 100644 index 00000000..a03cb6b5 --- /dev/null +++ b/reload_test.go @@ -0,0 +1,469 @@ +package modular + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +// mockReloadable is a test double for the Reloadable interface. +type mockReloadable struct { + canReload bool + timeout time.Duration + reloadErr error + reloadCalls atomic.Int32 + lastChanges []ConfigChange + mu sync.Mutex +} + +func (m *mockReloadable) Reload(_ context.Context, changes []ConfigChange) error { + m.reloadCalls.Add(1) + m.mu.Lock() + m.lastChanges = changes + m.mu.Unlock() + return m.reloadErr +} + +func (m *mockReloadable) CanReload() bool { return m.canReload } +func (m *mockReloadable) ReloadTimeout() time.Duration { return m.timeout } + +func (m *mockReloadable) getLastChanges() []ConfigChange { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]ConfigChange, len(m.lastChanges)) + copy(result, m.lastChanges) + return result +} + +// reloadTestLogger implements Logger for testing. +type reloadTestLogger struct { + mu sync.Mutex + messages []string +} + +func (l *reloadTestLogger) Info(msg string, args ...any) { l.record("INFO", msg, args...) } +func (l *reloadTestLogger) Error(msg string, args ...any) { l.record("ERROR", msg, args...) } +func (l *reloadTestLogger) Warn(msg string, args ...any) { l.record("WARN", msg, args...) } +func (l *reloadTestLogger) Debug(msg string, args ...any) { l.record("DEBUG", msg, args...) } + +func (l *reloadTestLogger) record(level, msg string, args ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, fmt.Sprintf("[%s] %s %v", level, msg, args)) +} + +// reloadTestSubject is a minimal Subject for capturing events in reload tests. +type reloadTestSubject struct { + mu sync.Mutex + events []cloudevents.Event +} + +func (s *reloadTestSubject) RegisterObserver(_ Observer, _ ...string) error { return nil } +func (s *reloadTestSubject) UnregisterObserver(_ Observer) error { return nil } +func (s *reloadTestSubject) GetObservers() []ObserverInfo { return nil } +func (s *reloadTestSubject) NotifyObservers(_ context.Context, event cloudevents.Event) error { + s.mu.Lock() + s.events = append(s.events, event) + s.mu.Unlock() + return nil +} + +func (s *reloadTestSubject) getEvents() []cloudevents.Event { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]cloudevents.Event, len(s.events)) + copy(result, s.events) + return result +} + +func (s *reloadTestSubject) eventTypes() []string { + s.mu.Lock() + defer s.mu.Unlock() + var types []string + for _, e := range s.events { + types = append(types, e.Type()) + } + return types +} + +// --- ConfigDiff tests --- + +func TestConfigDiff_HasChanges(t *testing.T) { + t.Run("empty diff", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + if d.HasChanges() { + t.Error("expected no changes") + } + }) + t.Run("with changed", func(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{"a": {OldValue: 1, NewValue: 2}}, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + if !d.HasChanges() { + t.Error("expected changes") + } + }) + t.Run("with added", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: map[string]FieldChange{"b": {NewValue: "x"}}, + Removed: make(map[string]FieldChange), + } + if !d.HasChanges() { + t.Error("expected changes") + } + }) + t.Run("with removed", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: map[string]FieldChange{"c": {OldValue: "y"}}, + } + if !d.HasChanges() { + t.Error("expected changes") + } + }) +} + +func TestConfigDiff_FilterByPrefix(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{ + "db.host": {OldValue: "old", NewValue: "new"}, + "db.port": {OldValue: 3306, NewValue: 5432}, + "cache.ttl": {OldValue: 30, NewValue: 60}, + }, + Added: map[string]FieldChange{ + "db.ssl": {NewValue: true}, + }, + Removed: map[string]FieldChange{ + "cache.max": {OldValue: 100}, + }, + } + + filtered := d.FilterByPrefix("db.") + if len(filtered.Changed) != 2 { + t.Errorf("expected 2 changed, got %d", len(filtered.Changed)) + } + if len(filtered.Added) != 1 { + t.Errorf("expected 1 added, got %d", len(filtered.Added)) + } + if len(filtered.Removed) != 0 { + t.Errorf("expected 0 removed, got %d", len(filtered.Removed)) + } + + cacheFiltered := d.FilterByPrefix("cache.") + if len(cacheFiltered.Changed) != 1 { + t.Errorf("expected 1 changed for cache prefix, got %d", len(cacheFiltered.Changed)) + } + if len(cacheFiltered.Removed) != 1 { + t.Errorf("expected 1 removed for cache prefix, got %d", len(cacheFiltered.Removed)) + } +} + +func TestConfigDiff_RedactSensitiveFields(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{ + "db.password": {OldValue: "secret1", NewValue: "secret2", IsSensitive: true}, + "db.host": {OldValue: "old", NewValue: "new", IsSensitive: false}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + + redacted := d.RedactSensitiveFields() + + pw := redacted.Changed["db.password"] + if pw.OldValue != "[REDACTED]" || pw.NewValue != "[REDACTED]" { + t.Errorf("sensitive field not redacted: old=%v new=%v", pw.OldValue, pw.NewValue) + } + + host := redacted.Changed["db.host"] + if host.OldValue != "old" || host.NewValue != "new" { + t.Errorf("non-sensitive field should not be redacted: old=%v new=%v", host.OldValue, host.NewValue) + } + + // Verify original is not mutated. + origPw := d.Changed["db.password"] + if origPw.OldValue != "secret1" { + t.Error("original diff should not be mutated") + } +} + +func TestConfigDiff_ChangeSummary(t *testing.T) { + t.Run("no changes", func(t *testing.T) { + d := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + } + s := d.ChangeSummary() + if s != "no changes" { + t.Errorf("expected 'no changes', got %q", s) + } + }) + t.Run("mixed changes", func(t *testing.T) { + d := ConfigDiff{ + Changed: map[string]FieldChange{"a": {}}, + Added: map[string]FieldChange{"b": {}, "c": {}}, + Removed: map[string]FieldChange{"d": {}}, + } + s := d.ChangeSummary() + if !strings.Contains(s, "2 added") { + t.Errorf("summary missing added count: %q", s) + } + if !strings.Contains(s, "1 modified") { + t.Errorf("summary missing modified count: %q", s) + } + if !strings.Contains(s, "1 removed") { + t.Errorf("summary missing removed count: %q", s) + } + }) +} + +// waitFor polls cond every 5ms until it returns true or timeout elapses. +// Returns true if cond was satisfied, false on timeout. +func waitFor(t *testing.T, timeout time.Duration, cond func() bool) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} + +// --- ReloadOrchestrator tests --- + +func newTestDiff() ConfigDiff { + return ConfigDiff{ + Changed: map[string]FieldChange{ + "db.host": {OldValue: "localhost", NewValue: "remotehost", ChangeType: ChangeModified}, + }, + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + Timestamp: time.Now(), + DiffID: "test-diff-1", + } +} + +func TestReloadOrchestrator_SuccessfulReload(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} + orch.RegisterReloadable("testmod", mod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { return mod.reloadCalls.Load() >= 1 }) { + t.Fatalf("timed out waiting for reload call, got %d", mod.reloadCalls.Load()) + } + + if !waitFor(t, 2*time.Second, func() bool { return len(subject.eventTypes()) >= 2 }) { + t.Fatalf("timed out waiting for events, got %d", len(subject.eventTypes())) + } + + events := subject.eventTypes() + if events[0] != EventTypeConfigReloadStarted { + t.Errorf("expected started event, got %s", events[0]) + } + if events[len(events)-1] != EventTypeConfigReloadCompleted { + t.Errorf("expected completed event, got %s", events[len(events)-1]) + } +} + +func TestReloadOrchestrator_PartialFailure_Rollback(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod1 := &mockReloadable{canReload: true, timeout: 5 * time.Second} + mod2 := &mockReloadable{canReload: true, timeout: 5 * time.Second, reloadErr: errors.New("boom")} + orch.RegisterReloadable("aaa_first", mod1) + orch.RegisterReloadable("zzz_second", mod2) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { + return len(subject.eventTypes()) > 0 && subject.eventTypes()[len(subject.eventTypes())-1] == EventTypeConfigReloadFailed + }) { + t.Fatal("timed out waiting for reload failure event") + } + + // Targets are sorted by name: aaa_first runs before zzz_second. + // aaa_first succeeds, then zzz_second fails, triggering rollback of aaa_first. + // So aaa_first gets 2 calls (apply + rollback) and zzz_second gets 1 call (the failure). + calls1 := mod1.reloadCalls.Load() + calls2 := mod2.reloadCalls.Load() + + if calls1 != 2 { + t.Errorf("expected aaa_first to be called 2 times (apply+rollback), got %d", calls1) + } + + if calls2 != 1 { + t.Errorf("expected zzz_second to be called 1 time (the failure), got %d", calls2) + } + + // Verify a failed event was emitted. + hasFailedEvent := false + for _, et := range subject.eventTypes() { + if et == EventTypeConfigReloadFailed { + hasFailedEvent = true + } + } + if !hasFailedEvent { + t.Error("expected ConfigReloadFailed event") + } +} + +func TestReloadOrchestrator_CircuitBreaker(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + failMod := &mockReloadable{canReload: true, timeout: 5 * time.Second, reloadErr: errors.New("fail")} + orch.RegisterReloadable("failing", failMod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + + // Trigger enough failures to open the circuit breaker. + for i := range circuitBreakerThreshold { + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload %d failed: %v", i, err) + } + expected := int32(i + 1) + if !waitFor(t, 2*time.Second, func() bool { return failMod.reloadCalls.Load() >= expected }) { + t.Fatalf("timed out waiting for reload call %d", i+1) + } + } + + // Next request should be rejected by the circuit breaker. + err := orch.RequestReload(ctx, ReloadManual, diff) + if err == nil { + t.Error("expected circuit breaker error, got nil") + } + if err != nil && !strings.Contains(err.Error(), "circuit breaker") { + t.Errorf("expected circuit breaker error, got: %v", err) + } +} + +func TestReloadOrchestrator_CanReloadFalse_Skipped(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: false, timeout: 5 * time.Second} + orch.RegisterReloadable("disabled", mod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + if err := orch.RequestReload(ctx, ReloadManual, diff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { + return slices.Contains(subject.eventTypes(), EventTypeConfigReloadCompleted) + }) { + t.Fatal("timed out waiting for ConfigReloadCompleted event") + } + + if mod.reloadCalls.Load() != 0 { + t.Errorf("expected 0 reload calls for disabled module, got %d", mod.reloadCalls.Load()) + } +} + +func TestReloadOrchestrator_ConcurrentRequests(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} + orch.RegisterReloadable("concurrent", mod) + + ctx := t.Context() + orch.Start(ctx) + + diff := newTestDiff() + + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + _ = orch.RequestReload(ctx, ReloadManual, diff) + }) + } + wg.Wait() + + if !waitFor(t, 2*time.Second, func() bool { return mod.reloadCalls.Load() >= 1 }) { + t.Fatalf("timed out waiting for at least 1 reload call, got %d", mod.reloadCalls.Load()) + } + + calls := mod.reloadCalls.Load() + // Due to single-flight, some may be skipped — that's expected. + t.Logf("concurrent test: %d reload calls processed out of 10 requests", calls) +} + +func TestReloadOrchestrator_NoopOnEmptyDiff(t *testing.T) { + logger := &reloadTestLogger{} + subject := &reloadTestSubject{} + orch := NewReloadOrchestrator(logger, subject) + + mod := &mockReloadable{canReload: true, timeout: 5 * time.Second} + orch.RegisterReloadable("mod", mod) + + ctx := t.Context() + orch.Start(ctx) + + emptyDiff := ConfigDiff{ + Changed: make(map[string]FieldChange), + Added: make(map[string]FieldChange), + Removed: make(map[string]FieldChange), + DiffID: "empty", + } + if err := orch.RequestReload(ctx, ReloadManual, emptyDiff); err != nil { + t.Fatalf("RequestReload failed: %v", err) + } + + if !waitFor(t, 2*time.Second, func() bool { + return slices.Contains(subject.eventTypes(), EventTypeConfigReloadNoop) + }) { + t.Fatal("timed out waiting for ConfigReloadNoop event") + } + + if mod.reloadCalls.Load() != 0 { + t.Errorf("expected 0 reload calls for empty diff, got %d", mod.reloadCalls.Load()) + } +} diff --git a/secret_resolver.go b/secret_resolver.go new file mode 100644 index 00000000..8a361df1 --- /dev/null +++ b/secret_resolver.go @@ -0,0 +1,79 @@ +package modular + +import ( + "context" + "fmt" + "regexp" +) + +// SecretResolver resolves secret references in configuration values. +type SecretResolver interface { + ResolveSecret(ctx context.Context, ref string) (string, error) + CanResolve(ref string) bool +} + +var secretRefPattern = regexp.MustCompile(`^\$\{([^:}]+:[^}]+)\}$`) + +// ExpandSecrets walks a config map and replaces string values matching +// ${prefix:path} with the resolved secret value. Recurses into nested maps. +func ExpandSecrets(ctx context.Context, config map[string]any, resolvers ...SecretResolver) error { + for key, val := range config { + switch v := val.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + config[key] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + case []any: + if err := expandSecretsSlice(ctx, v, resolvers); err != nil { + return fmt.Errorf("resolving %q: %w", key, err) + } + } + } + return nil +} + +func expandSecretsSlice(ctx context.Context, slice []any, resolvers []SecretResolver) error { + for i, elem := range slice { + switch v := elem.(type) { + case string: + resolved, err := resolveSecretString(ctx, v, resolvers) + if err != nil { + return err + } + slice[i] = resolved + case map[string]any: + if err := ExpandSecrets(ctx, v, resolvers...); err != nil { + return err + } + case []any: + if err := expandSecretsSlice(ctx, v, resolvers); err != nil { + return err + } + } + } + return nil +} + +func resolveSecretString(ctx context.Context, val string, resolvers []SecretResolver) (string, error) { + match := secretRefPattern.FindStringSubmatch(val) + if match == nil { + return val, nil + } + ref := match[1] + for _, r := range resolvers { + if r.CanResolve(ref) { + resolved, err := r.ResolveSecret(ctx, ref) + if err != nil { + return "", fmt.Errorf("resolving secret %q: %w", ref, err) + } + return resolved, nil + } + } + return val, nil +} diff --git a/secret_resolver_test.go b/secret_resolver_test.go new file mode 100644 index 00000000..72e7cb81 --- /dev/null +++ b/secret_resolver_test.go @@ -0,0 +1,67 @@ +package modular + +import ( + "context" + "strings" + "testing" +) + +type mockSecretResolver struct { + prefix string + values map[string]string +} + +func (r *mockSecretResolver) CanResolve(ref string) bool { + return strings.HasPrefix(ref, r.prefix+":") +} + +func (r *mockSecretResolver) ResolveSecret(ctx context.Context, ref string) (string, error) { + key := strings.TrimPrefix(ref, r.prefix+":") + if v, ok := r.values[key]; ok { + return v, nil + } + return "", ErrServiceNotFound +} + +func TestExpandSecrets_ResolvesRefs(t *testing.T) { + resolver := &mockSecretResolver{ + prefix: "vault", + values: map[string]string{"secret/db-pass": "s3cret"}, + } + config := map[string]any{ + "host": "localhost", + "password": "${vault:secret/db-pass}", + "nested": map[string]any{"key": "${vault:secret/db-pass}"}, + } + if err := ExpandSecrets(context.Background(), config, resolver); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "s3cret" { + t.Errorf("expected s3cret, got %v", config["password"]) + } + nested := config["nested"].(map[string]any) + if nested["key"] != "s3cret" { + t.Errorf("expected nested s3cret, got %v", nested["key"]) + } +} + +func TestExpandSecrets_SkipsNonRefs(t *testing.T) { + config := map[string]any{"host": "localhost", "port": 5432} + if err := ExpandSecrets(context.Background(), config); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["host"] != "localhost" { + t.Errorf("expected localhost, got %v", config["host"]) + } +} + +func TestExpandSecrets_NoMatchingResolver(t *testing.T) { + config := map[string]any{"password": "${aws:secret/key}"} + resolver := &mockSecretResolver{prefix: "vault", values: map[string]string{}} + if err := ExpandSecrets(context.Background(), config, resolver); err != nil { + t.Fatalf("ExpandSecrets: %v", err) + } + if config["password"] != "${aws:secret/key}" { + t.Errorf("expected unchanged ref, got %v", config["password"]) + } +} diff --git a/service.go b/service.go index eb81a156..d7d2d475 100644 --- a/service.go +++ b/service.go @@ -3,6 +3,7 @@ package modular import ( "fmt" "reflect" + "sync" ) // ServiceRegistry allows registration and retrieval of services by name. @@ -35,6 +36,8 @@ type ServiceRegistryEntry struct { // EnhancedServiceRegistry provides enhanced service registry functionality // that tracks module associations and handles automatic conflict resolution. type EnhancedServiceRegistry struct { + mu sync.RWMutex + // services maps service names to their registry entries services map[string]*ServiceRegistryEntry @@ -46,6 +49,9 @@ type EnhancedServiceRegistry struct { // currentModule tracks the module currently being initialized currentModule Module + + // readyCallbacks stores callbacks waiting for a service to be registered + readyCallbacks map[string][]func(any) } // NewEnhancedServiceRegistry creates a new enhanced service registry. @@ -54,31 +60,71 @@ func NewEnhancedServiceRegistry() *EnhancedServiceRegistry { services: make(map[string]*ServiceRegistryEntry), moduleServices: make(map[string][]string), nameCounters: make(map[string]int), + readyCallbacks: make(map[string][]func(any)), } } // SetCurrentModule sets the module that is currently being initialized. // This is used to track which module is registering services. func (r *EnhancedServiceRegistry) SetCurrentModule(module Module) { + r.mu.Lock() + defer r.mu.Unlock() r.currentModule = module } // ClearCurrentModule clears the current module context. func (r *EnhancedServiceRegistry) ClearCurrentModule() { + r.mu.Lock() + defer r.mu.Unlock() r.currentModule = nil } +// RegisterServiceForModule registers a service with explicit module association, +// bypassing the shared currentModule field. This is safe for concurrent use +// during parallel module initialization. +func (r *EnhancedServiceRegistry) RegisterServiceForModule(name string, service any, module Module) (string, error) { + var moduleName string + var moduleType reflect.Type + if module != nil { + moduleName = module.Name() + moduleType = reflect.TypeOf(module) + } + return r.registerAndNotify(name, service, moduleName, moduleType) +} + // RegisterService registers a service with automatic conflict resolution. // If a service name conflicts, it will automatically append module information. func (r *EnhancedServiceRegistry) RegisterService(name string, service any) (string, error) { var moduleName string var moduleType reflect.Type + r.mu.Lock() if r.currentModule != nil { moduleName = r.currentModule.Name() moduleType = reflect.TypeOf(r.currentModule) } + r.mu.Unlock() + return r.registerAndNotify(name, service, moduleName, moduleType) +} + +// registerAndNotify performs service registration under the lock, +// then fires readiness callbacks outside the lock to avoid deadlocks. +func (r *EnhancedServiceRegistry) registerAndNotify(name string, service any, moduleName string, moduleType reflect.Type) (string, error) { + r.mu.Lock() + callbacksToFire, actualName := r.registerServiceInner(name, service, moduleName, moduleType) + r.mu.Unlock() + + for _, cb := range callbacksToFire { + cb(service) + } + + return actualName, nil +} + +// registerServiceInner does the actual registration work under the lock. +// Returns callbacks to fire and the actual service name. +func (r *EnhancedServiceRegistry) registerServiceInner(name string, service any, moduleName string, moduleType reflect.Type) ([]func(any), string) { // Generate unique name handling conflicts actualName := r.generateUniqueName(name, moduleName, moduleType) @@ -94,16 +140,27 @@ func (r *EnhancedServiceRegistry) RegisterService(name string, service any) (str // Register the service r.services[actualName] = entry + // Collect callbacks to fire outside the lock + var callbacksToFire []func(any) + for _, cbName := range []string{name, actualName} { + if callbacks, ok := r.readyCallbacks[cbName]; ok { + callbacksToFire = append(callbacksToFire, callbacks...) + delete(r.readyCallbacks, cbName) + } + } + // Track module associations if moduleName != "" { r.moduleServices[moduleName] = append(r.moduleServices[moduleName], actualName) } - return actualName, nil + return callbacksToFire, actualName } // GetService retrieves a service by name. func (r *EnhancedServiceRegistry) GetService(name string) (any, bool) { + r.mu.RLock() + defer r.mu.RUnlock() entry, exists := r.services[name] if !exists { return nil, false @@ -113,17 +170,44 @@ func (r *EnhancedServiceRegistry) GetService(name string) (any, bool) { // GetServiceEntry retrieves the full service registry entry. func (r *EnhancedServiceRegistry) GetServiceEntry(name string) (*ServiceRegistryEntry, bool) { + r.mu.RLock() + defer r.mu.RUnlock() entry, exists := r.services[name] return entry, exists } // GetServicesByModule returns all services provided by a specific module. +// Returns a copy of the internal slice for thread safety. func (r *EnhancedServiceRegistry) GetServicesByModule(moduleName string) []string { - return r.moduleServices[moduleName] + r.mu.RLock() + defer r.mu.RUnlock() + src := r.moduleServices[moduleName] + if src == nil { + return nil + } + dst := make([]string, len(src)) + copy(dst, src) + return dst +} + +// OnServiceReady registers a callback that fires when the named service is registered. +// If the service is already registered, the callback fires immediately. +func (r *EnhancedServiceRegistry) OnServiceReady(name string, callback func(any)) { + r.mu.Lock() + entry, exists := r.services[name] + if exists { + r.mu.Unlock() + callback(entry.Service) + return + } + r.readyCallbacks[name] = append(r.readyCallbacks[name], callback) + r.mu.Unlock() } // GetServicesByInterface returns all services that implement the given interface. func (r *EnhancedServiceRegistry) GetServicesByInterface(interfaceType reflect.Type) []*ServiceRegistryEntry { + r.mu.RLock() + defer r.mu.RUnlock() var results []*ServiceRegistryEntry for _, entry := range r.services { @@ -141,6 +225,8 @@ func (r *EnhancedServiceRegistry) GetServicesByInterface(interfaceType reflect.T // AsServiceRegistry returns a backwards-compatible ServiceRegistry view. func (r *EnhancedServiceRegistry) AsServiceRegistry() ServiceRegistry { + r.mu.RLock() + defer r.mu.RUnlock() registry := make(ServiceRegistry) for name, entry := range r.services { registry[name] = entry.Service @@ -167,7 +253,10 @@ func (r *EnhancedServiceRegistry) generateUniqueName(originalName, moduleName st // Still conflicts - try with module type name if moduleType != nil { - typeName := moduleType.Elem().Name() + var typeName string + if moduleType.Kind() == reflect.Ptr { + typeName = moduleType.Elem().Name() + } if typeName == "" { typeName = moduleType.String() } diff --git a/service_readiness_test.go b/service_readiness_test.go new file mode 100644 index 00000000..c630fdf6 --- /dev/null +++ b/service_readiness_test.go @@ -0,0 +1,52 @@ +package modular + +import ( + "sync/atomic" + "testing" +) + +func TestOnServiceReady_AlreadyRegistered(t *testing.T) { + registry := NewEnhancedServiceRegistry() + registry.RegisterService("db", "postgres-conn") + var called atomic.Bool + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + if svc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", svc) + } + }) + if !called.Load() { + t.Error("callback should have been called immediately") + } +} + +func TestOnServiceReady_DeferredUntilRegistration(t *testing.T) { + registry := NewEnhancedServiceRegistry() + var called atomic.Bool + var receivedSvc any + registry.OnServiceReady("db", func(svc any) { + called.Store(true) + receivedSvc = svc + }) + if called.Load() { + t.Error("callback should not have been called yet") + } + registry.RegisterService("db", "postgres-conn") + if !called.Load() { + t.Error("callback should have been called after registration") + } + if receivedSvc != "postgres-conn" { + t.Errorf("expected postgres-conn, got %v", receivedSvc) + } +} + +func TestOnServiceReady_MultipleCallbacks(t *testing.T) { + registry := NewEnhancedServiceRegistry() + var count atomic.Int32 + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.OnServiceReady("cache", func(svc any) { count.Add(1) }) + registry.RegisterService("cache", "redis") + if count.Load() != 2 { + t.Errorf("expected 2 callbacks, got %d", count.Load()) + } +} diff --git a/service_registration_timing_test.go b/service_registration_timing_test.go index d4e903fb..13ac632e 100644 --- a/service_registration_timing_test.go +++ b/service_registration_timing_test.go @@ -124,7 +124,7 @@ type serviceConsumerModule struct { requiredService string dependencies []string serviceReceived bool - receivedValue interface{} + receivedValue any } func (m *serviceConsumerModule) Name() string { @@ -137,7 +137,7 @@ func (m *serviceConsumerModule) Dependencies() []string { func (m *serviceConsumerModule) Init(app Application) error { // Try to get the required service during Init - var service interface{} + var service any err := app.GetService(m.requiredService, &service) if err != nil { return err @@ -270,7 +270,7 @@ type serviceConsumerWithRequires struct { requiredServices []ServiceDependency dependencies []string servicesInjected bool - injectedService interface{} + injectedService any } func (m *serviceConsumerWithRequires) Name() string { @@ -315,7 +315,7 @@ type serviceConsumerWithDeclaredRequires struct { requiredServices []ServiceDependency requiredService string serviceReceived bool - receivedValue interface{} + receivedValue any } func (m *serviceConsumerWithDeclaredRequires) Name() string { @@ -324,7 +324,7 @@ func (m *serviceConsumerWithDeclaredRequires) Name() string { func (m *serviceConsumerWithDeclaredRequires) Init(app Application) error { // Try to get the required service during Init - var service interface{} + var service any err := app.GetService(m.requiredService, &service) if err != nil { return err diff --git a/service_registry_scenarios_bdd_test.go b/service_registry_scenarios_bdd_test.go index b9f0f30e..13caa348 100644 --- a/service_registry_scenarios_bdd_test.go +++ b/service_registry_scenarios_bdd_test.go @@ -83,7 +83,7 @@ func (ctx *EnhancedServiceRegistryBDDContext) iQueryForServicesByInterfaceType() } // Query for services implementing TestServiceInterface - interfaceType := reflect.TypeOf((*TestServiceInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestServiceInterface]() ctx.retrievedServices = ctx.app.GetServicesByInterface(interfaceType) return nil } @@ -173,7 +173,7 @@ func (ctx *EnhancedServiceRegistryBDDContext) eachServiceShouldGetAUniqueNameThr } func (ctx *EnhancedServiceRegistryBDDContext) allServicesShouldBeDiscoverableByInterface() error { - interfaceType := reflect.TypeOf((*TestServiceInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestServiceInterface]() services := ctx.app.GetServicesByInterface(interfaceType) if len(services) != 3 { diff --git a/service_typed.go b/service_typed.go new file mode 100644 index 00000000..c2e8b0d8 --- /dev/null +++ b/service_typed.go @@ -0,0 +1,28 @@ +package modular + +import "fmt" + +// RegisterTypedService registers a service with compile-time type safety. +func RegisterTypedService[T any](app Application, name string, svc T) error { + if err := app.RegisterService(name, svc); err != nil { + return fmt.Errorf("registering typed service %q: %w", name, err) + } + return nil +} + +// GetTypedService retrieves a service with compile-time type safety. +// Note: This uses SvcRegistry() which copies the map. For hot paths, +// consider using app.GetService() with a concrete target type instead. +func GetTypedService[T any](app Application, name string) (T, error) { + var zero T + svcRegistry := app.SvcRegistry() + raw, exists := svcRegistry[name] + if !exists { + return zero, fmt.Errorf("%w: %s", ErrServiceNotFound, name) + } + typed, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: service %q is %T, want %T", ErrServiceWrongType, name, raw, zero) + } + return typed, nil +} diff --git a/service_typed_test.go b/service_typed_test.go new file mode 100644 index 00000000..57f55871 --- /dev/null +++ b/service_typed_test.go @@ -0,0 +1,37 @@ +package modular + +import "testing" + +type testTypedService struct{ Value string } + +func TestRegisterTypedService_and_GetTypedService(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + svc := &testTypedService{Value: "hello"} + if err := RegisterTypedService(app, "test.svc", svc); err != nil { + t.Fatalf("RegisterTypedService: %v", err) + } + got, err := GetTypedService[*testTypedService](app, "test.svc") + if err != nil { + t.Fatalf("GetTypedService: %v", err) + } + if got.Value != "hello" { + t.Errorf("expected hello, got %s", got.Value) + } +} + +func TestGetTypedService_WrongType(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _ = RegisterTypedService(app, "str.svc", "hello") + _, err := GetTypedService[int](app, "str.svc") + if err == nil { + t.Fatal("expected type mismatch error") + } +} + +func TestGetTypedService_NotFound(t *testing.T) { + app := NewStdApplication(NewStdConfigProvider(&struct{}{}), nopLogger{}) + _, err := GetTypedService[string](app, "missing") + if err == nil { + t.Fatal("expected not found error") + } +} diff --git a/slog_adapter.go b/slog_adapter.go new file mode 100644 index 00000000..d703583e --- /dev/null +++ b/slog_adapter.go @@ -0,0 +1,28 @@ +package modular + +import "log/slog" + +// SlogAdapter wraps a *slog.Logger to implement the Logger interface. +type SlogAdapter struct { + logger *slog.Logger +} + +// NewSlogAdapter creates a new SlogAdapter wrapping the given slog.Logger. +func NewSlogAdapter(l *slog.Logger) *SlogAdapter { + return &SlogAdapter{logger: l} +} + +func (a *SlogAdapter) Info(msg string, args ...any) { a.logger.Info(msg, args...) } +func (a *SlogAdapter) Error(msg string, args ...any) { a.logger.Error(msg, args...) } +func (a *SlogAdapter) Warn(msg string, args ...any) { a.logger.Warn(msg, args...) } +func (a *SlogAdapter) Debug(msg string, args ...any) { a.logger.Debug(msg, args...) } + +// With returns a new SlogAdapter with the given key-value pairs added to the context. +func (a *SlogAdapter) With(args ...any) *SlogAdapter { + return &SlogAdapter{logger: a.logger.With(args...)} +} + +// WithGroup returns a new SlogAdapter with the given group name. +func (a *SlogAdapter) WithGroup(name string) *SlogAdapter { + return &SlogAdapter{logger: a.logger.WithGroup(name)} +} diff --git a/slog_adapter_test.go b/slog_adapter_test.go new file mode 100644 index 00000000..a3b774fa --- /dev/null +++ b/slog_adapter_test.go @@ -0,0 +1,51 @@ +package modular + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestSlogAdapter_ImplementsLogger(t *testing.T) { + var _ Logger = (*SlogAdapter)(nil) +} + +func TestSlogAdapter_DelegatesToSlog(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(handler) + adapter := NewSlogAdapter(logger) + + adapter.Info("test info", "key", "value") + adapter.Error("test error", "err", "fail") + adapter.Warn("test warn") + adapter.Debug("test debug") + + output := buf.String() + for _, msg := range []string{"test info", "test error", "test warn", "test debug"} { + if !strings.Contains(output, msg) { + t.Errorf("expected %q in output", msg) + } + } +} + +func TestSlogAdapter_With(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + adapter := NewSlogAdapter(slog.New(handler)).With("module", "test") + adapter.Info("with test") + if !strings.Contains(buf.String(), "module=test") { + t.Errorf("expected module=test in output, got: %s", buf.String()) + } +} + +func TestSlogAdapter_WithGroup(t *testing.T) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + adapter := NewSlogAdapter(slog.New(handler)).WithGroup("mygroup") + adapter.Info("group test", "key", "val") + if !strings.Contains(buf.String(), "mygroup") { + t.Errorf("expected mygroup in output, got: %s", buf.String()) + } +} diff --git a/tenant_config_affixed_env_bug_test.go b/tenant_config_affixed_env_bug_test.go index 39dd7388..531d29d1 100644 --- a/tenant_config_affixed_env_bug_test.go +++ b/tenant_config_affixed_env_bug_test.go @@ -7,7 +7,7 @@ import ( "regexp" "testing" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // TestTenantConfigAffixedEnvBug tests the specific bug where tenant config loading diff --git a/tenant_config_file_loader.go b/tenant_config_file_loader.go index 71310d06..0601a16e 100644 --- a/tenant_config_file_loader.go +++ b/tenant_config_file_loader.go @@ -9,7 +9,7 @@ import ( "regexp" "strings" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular/feeders" ) // Static errors for better error handling @@ -482,13 +482,13 @@ func getSectionNames(sections map[string]ConfigProvider) []string { // cloneConfigWithValues creates a new instance of the originalConfig type // and copies values from loadedConfig into it -func cloneConfigWithValues(originalConfig, loadedConfig interface{}) (interface{}, error) { +func cloneConfigWithValues(originalConfig, loadedConfig any) (any, error) { if originalConfig == nil || loadedConfig == nil { return nil, ErrOriginalOrLoadedNil } origType := reflect.TypeOf(originalConfig) - if origType.Kind() == reflect.Ptr { + if origType.Kind() == reflect.Pointer { origType = origType.Elem() } @@ -504,21 +504,21 @@ func cloneConfigWithValues(originalConfig, loadedConfig interface{}) (interface{ } // copyStructFields copies field values from src to dst -func copyStructFields(dst, src interface{}) error { +func copyStructFields(dst, src any) error { dstVal := reflect.ValueOf(dst) srcVal := reflect.ValueOf(src) // Ensure we're working with pointers - if dstVal.Kind() != reflect.Ptr { + if dstVal.Kind() != reflect.Pointer { return ErrDestinationNotPointer } // Dereference pointers to get the underlying values - if dstVal.Kind() == reflect.Ptr { + if dstVal.Kind() == reflect.Pointer { dstVal = dstVal.Elem() } - if srcVal.Kind() == reflect.Ptr { + if srcVal.Kind() == reflect.Pointer { srcVal = srcVal.Elem() } diff --git a/tenant_config_loader_test.go b/tenant_config_loader_test.go index f9462133..4476ac78 100644 --- a/tenant_config_loader_test.go +++ b/tenant_config_loader_test.go @@ -2,6 +2,7 @@ package modular import ( "log/slog" + "maps" "os" "path/filepath" "regexp" @@ -43,9 +44,7 @@ func (m *MockTenantService) RegisterTenant(tenantID TenantID, configs map[string m.tenants[tenantID] = make(map[string]ConfigProvider) } - for section, provider := range configs { - m.tenants[tenantID][section] = provider - } + maps.Copy(m.tenants[tenantID], configs) return nil } diff --git a/tenant_config_provider.go b/tenant_config_provider.go index cb552047..4a905196 100644 --- a/tenant_config_provider.go +++ b/tenant_config_provider.go @@ -65,7 +65,7 @@ func (tcp *TenantConfigProvider) SetTenantConfig(tenantID TenantID, section stri // Ensure the config is a valid, non-zero value cfgValue := reflect.ValueOf(cfg) - if cfgValue.Kind() == reflect.Ptr && cfgValue.IsNil() { + if cfgValue.Kind() == reflect.Pointer && cfgValue.IsNil() { return } diff --git a/tenant_config_test.go b/tenant_config_test.go index f549e545..60b37c03 100644 --- a/tenant_config_test.go +++ b/tenant_config_test.go @@ -248,7 +248,7 @@ func TestLoadTenantConfigsNonexistentDirectory(t *testing.T) { ConfigDir: nonExistentDir, } - log.On("Error", "Tenant config directory does not exist", []interface{}{"directory", nonExistentDir}).Return(nil) + log.On("Error", "Tenant config directory does not exist", []any{"directory", nonExistentDir}).Return(nil) err := LoadTenantConfigs(app, tenantService, params) if err == nil || !strings.Contains(err.Error(), "tenant config directory does not exist") { t.Errorf("Expected error for nonexistent directory, got: %v", err) @@ -323,7 +323,7 @@ func TestTenantConfigProviderSetAndGet(t *testing.T) { } // Test nil config - nilProviderStruct := &struct{ Config interface{} }{nil} + nilProviderStruct := &struct{ Config any }{nil} nilProvider := NewStdConfigProvider(nilProviderStruct.Config) tcp.SetTenantConfig(tenant1ID, "NilConfigSection", nilProvider) if tcp.HasTenantConfig(tenant1ID, "NilConfigSection") { @@ -403,7 +403,7 @@ func TestCopyStructFields(t *testing.T) { } // Test copying map to struct - srcMap := map[string]interface{}{ + srcMap := map[string]any{ "Name": "MapSource", "Environment": "prod", "Features": map[string]bool{"feature2": true}, diff --git a/tenant_guard.go b/tenant_guard.go new file mode 100644 index 00000000..625ab801 --- /dev/null +++ b/tenant_guard.go @@ -0,0 +1,294 @@ +package modular + +import ( + "context" + "fmt" + "sync" + "time" +) + +// TenantGuardMode controls how the tenant guard responds to violations. +type TenantGuardMode int + +const ( + // TenantGuardStrict blocks the operation and returns an error on violation. + TenantGuardStrict TenantGuardMode = iota + // TenantGuardLenient records the violation and allows the operation to proceed. + // Violations are logged when LogViolations is true and a logger is configured. + TenantGuardLenient + // TenantGuardDisabled performs no validation at all. + TenantGuardDisabled +) + +// String returns the string representation of a TenantGuardMode. +func (m TenantGuardMode) String() string { + switch m { + case TenantGuardStrict: + return "strict" + case TenantGuardLenient: + return "lenient" + case TenantGuardDisabled: + return "disabled" + default: + return fmt.Sprintf("unknown(%d)", int(m)) + } +} + +// ViolationType categorizes the kind of tenant boundary violation. +type ViolationType int + +const ( + // CrossTenant indicates an attempt to access another tenant's resources. + CrossTenant ViolationType = iota + // InvalidContext indicates the tenant context is malformed or invalid. + InvalidContext + // MissingContext indicates no tenant context was provided. + MissingContext + // Unauthorized indicates the caller lacks permission for the tenant operation. + Unauthorized +) + +// String returns the string representation of a ViolationType. +func (v ViolationType) String() string { + switch v { + case CrossTenant: + return "cross_tenant" + case InvalidContext: + return "invalid_context" + case MissingContext: + return "missing_context" + case Unauthorized: + return "unauthorized" + default: + return fmt.Sprintf("unknown(%d)", int(v)) + } +} + +// Severity indicates the severity level of a tenant violation. +type Severity int + +const ( + // SeverityLow indicates a minor violation. + SeverityLow Severity = iota + // SeverityMedium indicates a moderate violation. + SeverityMedium + // SeverityHigh indicates a serious violation. + SeverityHigh + // SeverityCritical indicates a critical violation requiring immediate attention. + SeverityCritical +) + +// String returns the string representation of a Severity. +func (s Severity) String() string { + switch s { + case SeverityLow: + return "low" + case SeverityMedium: + return "medium" + case SeverityHigh: + return "high" + case SeverityCritical: + return "critical" + default: + return fmt.Sprintf("unknown(%d)", int(s)) + } +} + +// TenantViolation represents a detected tenant boundary violation. +type TenantViolation struct { + Type ViolationType + Severity Severity + TenantID string + TargetID string + Timestamp time.Time + Details string +} + +// TenantGuard validates tenant access and tracks violations. +type TenantGuard interface { + // GetMode returns the current guard mode. + GetMode() TenantGuardMode + + // ValidateAccess checks whether the given violation should be blocked. + // In Strict mode, it returns an error. In Lenient mode, it records the + // violation but returns nil. In Disabled mode, it is a no-op. + ValidateAccess(ctx context.Context, violation TenantViolation) error + + // GetRecentViolations returns a deep copy of recent violations, ordered oldest-first. + GetRecentViolations() []TenantViolation +} + +// TenantGuardConfig holds configuration for a StandardTenantGuard. +type TenantGuardConfig struct { + Mode TenantGuardMode + Whitelist map[string][]string // tenantID -> allowed target IDs + MaxViolations int // ring buffer capacity, default 1000 + LogViolations bool // whether to log violations, default true +} + +// DefaultTenantGuardConfig returns a TenantGuardConfig with sensible defaults. +func DefaultTenantGuardConfig() TenantGuardConfig { + return TenantGuardConfig{ + Mode: TenantGuardStrict, + Whitelist: make(map[string][]string), + MaxViolations: 1000, + LogViolations: true, + } +} + +// TenantGuardOption is a functional option for configuring a StandardTenantGuard. +type TenantGuardOption func(*StandardTenantGuard) + +// WithTenantGuardLogger sets a structured logger on the guard. +func WithTenantGuardLogger(l Logger) TenantGuardOption { + return func(g *StandardTenantGuard) { + g.logger = l + } +} + +// WithTenantGuardSubject sets a Subject for event emission on the guard. +func WithTenantGuardSubject(s Subject) TenantGuardOption { + return func(g *StandardTenantGuard) { + g.subject = s + } +} + +// StandardTenantGuard is the default TenantGuard implementation. +// It uses a ring buffer to store recent violations and optionally emits +// CloudEvents when violations are detected. +type StandardTenantGuard struct { + config TenantGuardConfig + whitelist map[string]map[string]struct{} // deep-copied set for fast lookups + violations []TenantViolation + head int + count int + mu sync.RWMutex + logger Logger + subject Subject +} + +// NewStandardTenantGuard creates a new StandardTenantGuard with the given config and options. +// The whitelist is deep-copied and converted to a set for safe, fast lookups. +func NewStandardTenantGuard(config TenantGuardConfig, opts ...TenantGuardOption) *StandardTenantGuard { + if config.MaxViolations <= 0 { + config.MaxViolations = 1000 + } + + // Deep-copy and convert whitelist to set + wl := make(map[string]map[string]struct{}, len(config.Whitelist)) + for tenant, targets := range config.Whitelist { + set := make(map[string]struct{}, len(targets)) + for _, t := range targets { + set[t] = struct{}{} + } + wl[tenant] = set + } + + g := &StandardTenantGuard{ + config: config, + whitelist: wl, + violations: make([]TenantViolation, config.MaxViolations), + } + + for _, opt := range opts { + opt(g) + } + + return g +} + +// GetMode returns the current guard mode. +func (g *StandardTenantGuard) GetMode() TenantGuardMode { + return g.config.Mode +} + +// ValidateAccess checks the violation against the guard's policy. +func (g *StandardTenantGuard) ValidateAccess(ctx context.Context, violation TenantViolation) error { + if g.config.Mode == TenantGuardDisabled { + return nil + } + + // Set timestamp if not provided + if violation.Timestamp.IsZero() { + violation.Timestamp = time.Now() + } + + // Check whitelist (set-based O(1) lookup) + if targets, ok := g.whitelist[violation.TenantID]; ok { + if _, allowed := targets[violation.TargetID]; allowed { + return nil + } + } + + // Record violation + g.mu.Lock() + g.addViolation(violation) + g.mu.Unlock() + + // Log if configured + if g.config.LogViolations && g.logger != nil { + g.logger.Warn("Tenant violation detected", + "type", violation.Type.String(), + "severity", violation.Severity.String(), + "tenant", violation.TenantID, + "target", violation.TargetID, + "details", violation.Details, + ) + } + + // Emit event using NewCloudEvent helper (sets ID, specversion, time) + if g.subject != nil { + event := NewCloudEvent(EventTypeTenantViolation, "com.modular.tenant.guard", violation, nil) + if err := g.subject.NotifyObservers(ctx, event); err != nil && g.logger != nil { + g.logger.Warn("Failed to emit tenant violation event", + "error", err, + "tenant", violation.TenantID, + "type", violation.Type.String(), + ) + } + } + + // In strict mode, return error + if g.config.Mode == TenantGuardStrict { + return ErrTenantIsolationViolation + } + + // Lenient mode: violation recorded, but allow the operation + return nil +} + +// GetRecentViolations returns a deep copy of recent violations ordered oldest-first. +func (g *StandardTenantGuard) GetRecentViolations() []TenantViolation { + g.mu.RLock() + defer g.mu.RUnlock() + + if g.count == 0 { + return nil + } + + result := make([]TenantViolation, g.count) + max := g.config.MaxViolations + + if g.count < max { + // Buffer not yet full — entries are at indices 0..count-1 + copy(result, g.violations[:g.count]) + } else { + // Buffer full — oldest is at head, wrap around + oldest := g.head % max + n := copy(result, g.violations[oldest:]) + copy(result[n:], g.violations[:oldest]) + } + + return result +} + +// addViolation writes a violation into the ring buffer. +// Caller must hold the write lock. +func (g *StandardTenantGuard) addViolation(v TenantViolation) { + max := g.config.MaxViolations + g.violations[g.head%max] = v + g.head++ + if g.count < max { + g.count++ + } +} diff --git a/tenant_guard_test.go b/tenant_guard_test.go new file mode 100644 index 00000000..21316c3b --- /dev/null +++ b/tenant_guard_test.go @@ -0,0 +1,335 @@ +package modular + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +// tenantGuardTestLogger is a Logger implementation that counts Warn calls for testing. +type tenantGuardTestLogger struct { + warnCalls atomic.Int32 +} + +func (l *tenantGuardTestLogger) Info(_ string, _ ...any) {} +func (l *tenantGuardTestLogger) Error(_ string, _ ...any) {} +func (l *tenantGuardTestLogger) Debug(_ string, _ ...any) {} +func (l *tenantGuardTestLogger) Warn(_ string, _ ...any) { + l.warnCalls.Add(1) +} + +func TestTenantGuardMode_String(t *testing.T) { + tests := []struct { + mode TenantGuardMode + want string + }{ + {TenantGuardStrict, "strict"}, + {TenantGuardLenient, "lenient"}, + {TenantGuardDisabled, "disabled"}, + {TenantGuardMode(99), "unknown(99)"}, + } + + for _, tt := range tests { + got := tt.mode.String() + if got != tt.want { + t.Errorf("TenantGuardMode(%d).String() = %q, want %q", int(tt.mode), got, tt.want) + } + } +} + +func TestViolationType_String(t *testing.T) { + tests := []struct { + vt ViolationType + want string + }{ + {CrossTenant, "cross_tenant"}, + {InvalidContext, "invalid_context"}, + {MissingContext, "missing_context"}, + {Unauthorized, "unauthorized"}, + {ViolationType(99), "unknown(99)"}, + } + + for _, tt := range tests { + got := tt.vt.String() + if got != tt.want { + t.Errorf("ViolationType(%d).String() = %q, want %q", int(tt.vt), got, tt.want) + } + } +} + +func TestSeverity_String(t *testing.T) { + tests := []struct { + sev Severity + want string + }{ + {SeverityLow, "low"}, + {SeverityMedium, "medium"}, + {SeverityHigh, "high"}, + {SeverityCritical, "critical"}, + {Severity(99), "unknown(99)"}, + } + + for _, tt := range tests { + got := tt.sev.String() + if got != tt.want { + t.Errorf("Severity(%d).String() = %q, want %q", int(tt.sev), got, tt.want) + } + } +} + +func TestStandardTenantGuard_StrictMode(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardStrict + guard := NewStandardTenantGuard(config) + + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-2", + Details: "cross-tenant data access", + }) + + if err == nil { + t.Fatal("expected error in strict mode, got nil") + } + if !errors.Is(err, ErrTenantIsolationViolation) { + t.Errorf("expected ErrTenantIsolationViolation, got %v", err) + } + + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation recorded, got %d", len(violations)) + } + if violations[0].TenantID != "tenant-1" { + t.Errorf("expected tenant-1, got %s", violations[0].TenantID) + } +} + +func TestStandardTenantGuard_LenientMode(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + + logger := &tenantGuardTestLogger{} + guard := NewStandardTenantGuard(config, WithTenantGuardLogger(logger)) + + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityMedium, + TenantID: "tenant-1", + TargetID: "tenant-2", + Details: "lenient test", + }) + + if err != nil { + t.Fatalf("expected nil error in lenient mode, got %v", err) + } + + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation recorded, got %d", len(violations)) + } + + if logger.warnCalls.Load() == 0 { + t.Error("expected log output for violation, got none") + } +} + +func TestStandardTenantGuard_DisabledMode(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardDisabled + guard := NewStandardTenantGuard(config) + + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityCritical, + TenantID: "tenant-1", + TargetID: "tenant-2", + }) + + if err != nil { + t.Fatalf("expected nil error in disabled mode, got %v", err) + } + + violations := guard.GetRecentViolations() + if len(violations) != 0 { + t.Errorf("expected 0 violations in disabled mode, got %d", len(violations)) + } +} + +func TestStandardTenantGuard_Whitelist(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardStrict + config.Whitelist = map[string][]string{ + "tenant-1": {"tenant-2", "tenant-3"}, + } + guard := NewStandardTenantGuard(config) + + // Whitelisted access should succeed + err := guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-2", + }) + if err != nil { + t.Fatalf("expected nil for whitelisted access, got %v", err) + } + + // Non-whitelisted access should fail in strict mode + err = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-99", + }) + if !errors.Is(err, ErrTenantIsolationViolation) { + t.Errorf("expected ErrTenantIsolationViolation for non-whitelisted access, got %v", err) + } + + // Only the non-whitelisted violation should be recorded + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } +} + +func TestStandardTenantGuard_RingBuffer(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.MaxViolations = 5 + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + // Add 8 violations to a buffer of size 5 + for i := range 8 { + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityLow, + TenantID: "tenant-1", + TargetID: "target-" + string(rune('A'+i)), + Details: "violation", + }) + } + + violations := guard.GetRecentViolations() + if len(violations) != 5 { + t.Fatalf("expected 5 violations (buffer size), got %d", len(violations)) + } + + // Oldest should be violation index 3 (target-D), newest should be index 7 (target-H) + expectedTargets := []string{"target-D", "target-E", "target-F", "target-G", "target-H"} + for i, v := range violations { + if v.TargetID != expectedTargets[i] { + t.Errorf("violation[%d].TargetID = %q, want %q", i, v.TargetID, expectedTargets[i]) + } + } +} + +func TestStandardTenantGuard_GetRecentViolations_DeepCopy(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityHigh, + TenantID: "tenant-1", + TargetID: "tenant-2", + Details: "original", + }) + + // Get a copy and modify it + copy1 := guard.GetRecentViolations() + copy1[0].Details = "modified" + + // Get another copy — it should still have the original value + copy2 := guard.GetRecentViolations() + if copy2[0].Details != "original" { + t.Errorf("internal state was mutated: expected 'original', got %q", copy2[0].Details) + } +} + +func TestStandardTenantGuard_ConcurrentAccess(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.MaxViolations = 100 + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + var wg sync.WaitGroup + for i := range 100 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: CrossTenant, + Severity: SeverityLow, + TenantID: "tenant-1", + TargetID: "tenant-2", + }) + }(i) + } + wg.Wait() + + violations := guard.GetRecentViolations() + if len(violations) != 100 { + t.Errorf("expected 100 violations from concurrent access, got %d", len(violations)) + } +} + +func TestStandardTenantGuard_TimestampAutoSet(t *testing.T) { + config := DefaultTenantGuardConfig() + config.Mode = TenantGuardLenient + config.LogViolations = false + guard := NewStandardTenantGuard(config) + + before := time.Now() + _ = guard.ValidateAccess(context.Background(), TenantViolation{ + Type: MissingContext, + Severity: SeverityMedium, + TenantID: "tenant-1", + }) + after := time.Now() + + violations := guard.GetRecentViolations() + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + + ts := violations[0].Timestamp + if ts.Before(before) || ts.After(after) { + t.Errorf("timestamp %v not between %v and %v", ts, before, after) + } +} + +func TestStandardTenantGuard_GetMode(t *testing.T) { + for _, mode := range []TenantGuardMode{TenantGuardStrict, TenantGuardLenient, TenantGuardDisabled} { + config := DefaultTenantGuardConfig() + config.Mode = mode + guard := NewStandardTenantGuard(config) + if guard.GetMode() != mode { + t.Errorf("GetMode() = %v, want %v", guard.GetMode(), mode) + } + } +} + +func TestStandardTenantGuard_DefaultMaxViolations(t *testing.T) { + config := DefaultTenantGuardConfig() + if config.MaxViolations != 1000 { + t.Errorf("DefaultTenantGuardConfig().MaxViolations = %d, want 1000", config.MaxViolations) + } + if !config.LogViolations { + t.Error("DefaultTenantGuardConfig().LogViolations should be true") + } + if config.Mode != TenantGuardStrict { + t.Errorf("DefaultTenantGuardConfig().Mode = %v, want strict", config.Mode) + } +} + +// Verify StandardTenantGuard satisfies the TenantGuard interface +var _ TenantGuard = (*StandardTenantGuard)(nil) diff --git a/tenant_service.go b/tenant_service.go index 80953420..ab42ed98 100644 --- a/tenant_service.go +++ b/tenant_service.go @@ -4,6 +4,7 @@ package modular import ( "fmt" + "slices" "sync" ) @@ -165,12 +166,10 @@ func (ts *StandardTenantService) RegisterTenantAwareModule(module TenantAwareMod defer ts.mutex.Unlock() // Check if the module is already registered to avoid duplicates - for _, existingModule := range ts.tenantAwareModules { - if existingModule == module { - ts.logger.Debug("Module already registered as tenant-aware", - "module", fmt.Sprintf("%T", module), "name", module.Name()) - return nil - } + if slices.Contains(ts.tenantAwareModules, module) { + ts.logger.Debug("Module already registered as tenant-aware", + "module", fmt.Sprintf("%T", module), "name", module.Name()) + return nil } ts.tenantAwareModules = append(ts.tenantAwareModules, module) diff --git a/user_scenario_integration_test.go b/user_scenario_integration_test.go index f596126e..4df7c9f2 100644 --- a/user_scenario_integration_test.go +++ b/user_scenario_integration_test.go @@ -34,7 +34,7 @@ func TestUserScenarioReproduction(t *testing.T) { t.Log("Service entry not found (expected for nil service)") } - interfaceType := reflect.TypeOf((*TestUserInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestUserInterface]() interfaceServices := app.GetServicesByInterface(interfaceType) t.Logf("Services implementing interface: %d", len(interfaceServices)) @@ -57,7 +57,7 @@ func TestBackwardsCompatibilityCheck(t *testing.T) { t.Errorf("Expected no entry for nonexistent service, got %v, %v", entry, found) } - interfaceType := reflect.TypeOf((*TestUserInterface)(nil)).Elem() + interfaceType := reflect.TypeFor[TestUserInterface]() interfaceServices := app.GetServicesByInterface(interfaceType) if len(interfaceServices) != 0 { t.Errorf("Expected no interface services, got %v", interfaceServices) @@ -92,7 +92,7 @@ func (m *testInterfaceConsumerModule) RequiresServices() []ServiceDependency { return []ServiceDependency{{ Name: "testInterface", MatchByInterface: true, - SatisfiesInterface: reflect.TypeOf((*TestUserInterface)(nil)).Elem(), + SatisfiesInterface: reflect.TypeFor[TestUserInterface](), Required: false, // Optional to avoid initialization failures }} } diff --git a/user_scenario_test.go b/user_scenario_test.go index 5a7755ea..891c3e48 100644 --- a/user_scenario_test.go +++ b/user_scenario_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/CrisisTextLine/modular" - "github.com/CrisisTextLine/modular/feeders" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/modular/feeders" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require"