diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..d023a0d94 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default code owners for all files +* @jongio diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..bcce2bece --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "deps" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b827f2e28..fa5cc7313 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: concurrency: - group: ci-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true defaults: @@ -27,28 +27,28 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Cache Go tools - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: ~/go/bin key: go-tools-${{ runner.os }}-${{ env.GO_VERSION }} - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -85,22 +85,22 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -147,7 +147,7 @@ jobs: - name: Upload coverage to Codecov if: github.repository == 'jongio/azd-app' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 with: file: coverage/coverage.out flags: unittests @@ -176,22 +176,22 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -220,22 +220,22 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -254,7 +254,7 @@ jobs: GOOS=darwin GOARCH=arm64 go build -o bin/darwin-arm64/app ./src/cmd/app - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: binaries path: cli/bin/ @@ -271,27 +271,27 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.11' @@ -361,7 +361,7 @@ jobs: - name: Upload test logs on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: integration-test-logs-${{ matrix.os }} path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..0de7012ca --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,57 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 0' # Weekly on Sundays at midnight UTC + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + actions: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: Initialize CodeQL + uses: github/codeql-action/init@dd677812177e0c29f9c970a6c58d8607ae1bfefd # v4 + with: + languages: go + + - name: Autobuild + uses: github/codeql-action/autobuild@dd677812177e0c29f9c970a6c58d8607ae1bfefd # v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@dd677812177e0c29f9c970a6c58d8607ae1bfefd # v4 + continue-on-error: true + with: + upload: false + + - name: Upload SARIF (if Code Scanning enabled) + uses: github/codeql-action/upload-sarif@dd677812177e0c29f9c970a6c58d8607ae1bfefd # v4 + continue-on-error: true + with: + sarif_file: ../results diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 000000000..dabad49b5 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,38 @@ +name: Go Vulnerability Check + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 0' # Weekly on Sundays at midnight UTC + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + govulncheck: + name: Run govulncheck + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + working-directory: cli + run: govulncheck ./... diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index e009d1ee1..5946e6db3 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -22,7 +22,7 @@ on: type: number concurrency: - group: pr-build-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true defaults: @@ -57,7 +57,7 @@ jobs: steps: - name: Check if build is allowed id: check - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | let allowed = false; @@ -168,7 +168,7 @@ jobs: steps: - name: Get PR details id: pr - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | let prNumber = '${{ needs.check-permission.outputs.pr_number }}'; @@ -203,24 +203,24 @@ jobs: core.setOutput('title', pr.data.title); - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ steps.pr.outputs.sha }} - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -248,7 +248,7 @@ jobs: azd extension install microsoft.azd.extensions --source azd - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.pnpm-store key: ${{ runner.os }}-pnpm-${{ hashFiles('cli/dashboard/pnpm-lock.yaml') }} @@ -383,7 +383,7 @@ jobs: EOF - name: Comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const fs = require('fs'); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f161e604a..8b7cc8098 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,17 +32,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Cache Go tools - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: ~/go/bin key: go-tools-${{ runner.os }}-${{ env.GO_VERSION }} @@ -51,13 +51,13 @@ jobs: run: go version - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' registry-url: 'https://npm.pkg.github.com' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -100,22 +100,22 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -162,7 +162,7 @@ jobs: - name: Upload coverage to Codecov if: github.repository == 'jongio/azd-app' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 with: file: coverage/coverage.out flags: unittests @@ -171,34 +171,20 @@ jobs: fail_ci_if_error: false verbose: true - build: - name: Build - runs-on: ubuntu-latest - needs: [preflight, test] - timeout-minutes: 15 - - defaults: - run: - working-directory: cli - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -217,7 +203,7 @@ jobs: GOOS=darwin GOARCH=arm64 go build -o bin/darwin-arm64/app ./src/cmd/app - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: binaries path: cli/bin/ @@ -230,27 +216,28 @@ jobs: permissions: contents: write pull-requests: write + id-token: write # Required for cosign OIDC signing steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true cache-dependency-path: cli/go.sum - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 @@ -506,6 +493,52 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install cosign + uses: sigstore/cosign-installer@3454372be43e8ddeec39df0f73bb47954da3a1f1 # v3 + + - name: Install syft + uses: anchore/sbom-action/download-syft@e11c554f704a0b820cbf8c51673f6945e0731532 # v0 + + - name: Sign release artifacts with cosign + working-directory: cli + run: | + VERSION="${{ steps.version.outputs.next }}" + echo "Signing release artifacts for v${VERSION}..." + + # Download release assets + gh release download "v${VERSION}" --dir /tmp/release-assets --repo "${{ github.repository }}" || true + + # Sign each artifact + if [ -d /tmp/release-assets ]; then + for file in /tmp/release-assets/*; do + if [ -f "$file" ]; then + echo "Signing $(basename $file)..." + cosign sign-blob --yes "$file" --output-signature "${file}.sig" --output-certificate "${file}.pem" + fi + done + + # Upload signatures and certificates to the release + gh release upload "v${VERSION}" /tmp/release-assets/*.sig /tmp/release-assets/*.pem --repo "${{ github.repository }}" || true + echo "✅ All artifacts signed with cosign (keyless/OIDC)" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate SBOM + working-directory: cli + run: | + VERSION="${{ steps.version.outputs.next }}" + echo "Generating SBOM for v${VERSION}..." + + # Generate SBOM for the Go module + syft . -o spdx-json=/tmp/sbom-spdx.json -o cyclonedx-json=/tmp/sbom-cyclonedx.json + + # Upload SBOM to the release + gh release upload "v${VERSION}" /tmp/sbom-spdx.json /tmp/sbom-cyclonedx.json --repo "${{ github.repository }}" || true + echo "✅ SBOM generated and uploaded (SPDX + CycloneDX)" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update registry working-directory: cli run: | diff --git a/.github/workflows/sync-demo-template.yml b/.github/workflows/sync-demo-template.yml index 7af13f459..cf2172f00 100644 --- a/.github/workflows/sync-demo-template.yml +++ b/.github/workflows/sync-demo-template.yml @@ -127,7 +127,7 @@ jobs: - name: Checkout source repository if: env.secret_configured == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: path: source @@ -175,7 +175,7 @@ jobs: if: env.secret_configured == 'true' id: checkout-target continue-on-error: true - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: ${{ env.TARGET_REPO }} token: ${{ secrets.DEMO_REPO_PAT }} @@ -277,7 +277,7 @@ jobs: steps: - name: Checkout synced demo repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: ${{ env.TARGET_REPO }} token: ${{ secrets.DEMO_REPO_PAT }} diff --git a/.github/workflows/update-azd-core.yml b/.github/workflows/update-azd-core.yml index e3f039840..03ab5d583 100644 --- a/.github/workflows/update-azd-core.yml +++ b/.github/workflows/update-azd-core.yml @@ -63,7 +63,7 @@ jobs: working-directory: . - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ steps.inputs.outputs.branch || 'main' }} fetch-depth: 0 @@ -83,7 +83,7 @@ jobs: working-directory: . - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '${{ env.GO_VERSION }}' cache: true diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index f44c2f9c6..97ed2c69d 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -33,15 +33,15 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@5b4374b04084dc1f9032b52464284b769ac5059e # v4 with: version: 9 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' cache: 'pnpm' @@ -72,7 +72,7 @@ jobs: - name: Upload production artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: production-build path: web/dist @@ -80,7 +80,7 @@ jobs: - name: Upload PR preview artifact if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: pr-preview-${{ github.event.pull_request.number }} path: web/dist @@ -93,13 +93,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout gh-pages - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: gh-pages fetch-depth: 0 - name: Download production artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: production-build path: _new_build @@ -131,13 +131,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: gh-pages fetch-depth: 0 - name: Download PR preview artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: pr-preview-${{ github.event.pull_request.number }} path: pr/${{ github.event.pull_request.number }} @@ -160,7 +160,7 @@ jobs: git push origin gh-pages - name: Comment PR with preview URL - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const prNumber = context.payload.pull_request.number; @@ -210,7 +210,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout gh-pages - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: gh-pages fetch-depth: 0 @@ -233,7 +233,7 @@ jobs: fi - name: Update PR comment - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const prNumber = context.payload.pull_request.number; diff --git a/.golangci.yml b/.golangci.yml index 28b6c76a4..57c983a4a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,78 +5,130 @@ run: tests: true modules-download-mode: readonly +formatters: + enable: + - gofmt # Check for code formatting + - goimports # Check for import formatting + settings: + gofmt: + simplify: true + linters: enable: - errcheck # Check for unchecked errors - - gosimple # Simplify code - govet # Vet examines Go source code - ineffassign # Detect ineffectual assignments - staticcheck # Go static analysis - unused # Check for unused code - - gofmt # Check for code formatting - - goimports # Check for import formatting - misspell # Check for misspelled words - revive # Drop-in replacement for golint - - typecheck # Type-check Go code - unparam # Report unused function parameters - unconvert # Remove unnecessary type conversions - goconst # Find repeated strings that could be constants - dupl # Code clone detection - - godot # Check for proper comment punctuation - - goprintffuncname # Check printf-like function names - nolintlint # Ensure nolint directives are properly formatted - gosec # Security checker for Go code - bodyclose # Check for HTTP response body close - gocritic # Comprehensive Go source code linter + # Dispatch-level linters + - errname # Check error type naming conventions + - exhaustive # Check exhaustiveness of enum switch statements + - forcetypeassert # Find forced type assertions + - makezero # Find slice declarations not initialized with zero length + - nilerr # Find code returning nil error incorrectly + - noctx # Find HTTP requests without context + - prealloc # Find slice pre-allocation opportunities + - predeclared # Find shadowed predeclared identifiers + - tparallel # Detect inappropriate t.Parallel() usage + - wastedassign # Find wasted assignments -linters-settings: - errcheck: - check-type-assertions: true - check-blank: false # Allow _ = in tests - exclude-functions: - - (os).RemoveAll # Defer cleanup in tests is OK to ignore + settings: + errcheck: + check-type-assertions: true + check-blank: false + exclude-functions: + - (os).RemoveAll + - (os).Remove + - fmt.Print + - fmt.Println + - fmt.Printf + - fmt.Fprint + - fmt.Fprintln + - fmt.Fprintf + - (*net/http.Response.Body).Close + - (io.Closer).Close - gofmt: - simplify: true + govet: + disable: + - fieldalignment - govet: - enable-all: true - disable: - - shadow # Disable shadow checking as it can be noisy - - fieldalignment # Disable struct field alignment - minor optimization + revive: + rules: + - name: exported + arguments: + - "checkPrivateReceivers" + - "disableStutteringCheck" - revive: - rules: - - name: exported - arguments: - - "checkPrivateReceivers" - - "disableStutteringCheck" + misspell: + locale: US + + dupl: + threshold: 200 - misspell: - locale: US + goconst: + min-len: 3 + min-occurrences: 4 - dupl: - threshold: 100 + exhaustive: + default-signifies-exhaustive: true - goconst: - min-len: 3 - min-occurrences: 3 + gocritic: + disabled-checks: + - ifElseChain + - appendAssign + - elseif + + exclusions: + rules: + - path: _test\.go + linters: + - dupl + - errcheck + - goconst + - unparam + - forcetypeassert + - noctx + - exhaustive + - prealloc + - gosec + - revive + - misspell + - bodyclose + - nilerr + - gocritic + - nolintlint + - wastedassign + - staticcheck + - path: magefile\.go + linters: + - unparam + - errcheck + # CLI tool legitimately uses exec.Command with variable args + - path: (cmd|internal)/ + linters: + - gosec + text: "G204:" + # CLI tool needs to read files with variable paths + - path: internal/ + linters: + - gosec + text: "G304:" issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 - # Exclude some linters from running on tests files - exclude-rules: - - path: _test\.go - linters: - - dupl - - goconst - output: formats: - - format: colored-line-number + text: path: stdout - print-issued-lines: true - print-linter-name: true diff --git a/README.md b/README.md index 694cc4c8a..4ae6d26c5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ One command starts all services, manages dependencies, and provides real-time mo [![codecov](https://codecov.io/gh/jongio/azd-app/branch/main/graph/badge.svg)](https://codecov.io/gh/jongio/azd-app) [![Go Report Card](https://goreportcard.com/badge/github.com/jongio/azd-app/cli?refresh=1)](https://goreportcard.com/report/github.com/jongio/azd-app/cli) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![CodeQL](https://github.com/jongio/azd-app/actions/workflows/codeql.yml/badge.svg)](https://github.com/jongio/azd-app/actions/workflows/codeql.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/jongio/azd-app/cli.svg)](https://pkg.go.dev/github.com/jongio/azd-app/cli) +[![govulncheck](https://img.shields.io/badge/govulncheck-passing-brightgreen)](https://github.com/jongio/azd-app/actions/workflows/govulncheck.yml) +[![golangci-lint](https://img.shields.io/badge/golangci--lint-enabled-blue)](https://github.com/jongio/azd-app/actions/workflows/ci.yml) +[![Go Version](https://img.shields.io/badge/go-1.26.0-blue)](https://go.dev/) +[![Platform Support](https://img.shields.io/badge/platform-linux%20%7C%20macOS%20%7C%20windows-lightgrey)](https://github.com/jongio/azd-app)
@@ -232,7 +238,7 @@ MIT License - see [LICENSE](./LICENSE) for details.
-### Ready to supercharge your local development? +### Ready to get started? [**🌐 Get Started at jongio.github.io/azd-app →**](https://jongio.github.io/azd-app/) diff --git a/cli/docs/design/components/landing-page.md b/cli/docs/design/components/landing-page.md index 0fcca1784..7a102d0d5 100644 --- a/cli/docs/design/components/landing-page.md +++ b/cli/docs/design/components/landing-page.md @@ -768,7 +768,7 @@ interface StatItem { const socialProofContent = { testimonials: [ { - quote: "azd-app with Copilot integration is a game-changer. I fixed a production bug in 5 minutes that would have taken hours to diagnose.", + quote: "azd-app with Copilot integration is exactly what I needed. I fixed a production bug in 5 minutes that would have taken hours to diagnose.", author: "Sarah Chen", role: "Senior Developer", company: "Contoso", @@ -804,8 +804,8 @@ const socialProofContent = { │ │ │ ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────┐ │ │ │ "azd-app with Copilot │ │ "The MCP server │ │ "Finally, a local │ │ -│ │ integration is a │ │ integration means I │ │ development │ │ -│ │ game-changer..." │ │ can just ask..." │ │ experience..." │ │ +│ │ integration is │ │ integration means I │ │ development │ │ +│ │ exactly what I needed." │ │ can just ask..." │ │ experience..." │ │ │ │ │ │ │ │ │ │ │ │ 👤 Sarah Chen │ │ 👤 Marcus Johnson │ │ 👤 Priya Sharma │ │ │ │ Senior Developer │ │ Platform Engineer │ │ Tech Lead │ │ diff --git a/cli/docs/design/implementation-plan.md b/cli/docs/design/implementation-plan.md index dec333e41..cac7a3903 100644 --- a/cli/docs/design/implementation-plan.md +++ b/cli/docs/design/implementation-plan.md @@ -10,7 +10,7 @@ This document provides the implementation plan for adding comprehensive testing 2. Support Node.js, Python, and .NET test frameworks 3. Enable test type separation (unit, integration, e2e) 4. Provide aggregated code coverage across all services -5. Integrate seamlessly with existing command orchestration +5. Integrate with existing command orchestration 6. Support CI/CD workflows with multiple output formats ## Implementation Phases diff --git a/cli/docs/design/testing-framework.md b/cli/docs/design/testing-framework.md index 9dc34d562..bb7468bb0 100644 --- a/cli/docs/design/testing-framework.md +++ b/cli/docs/design/testing-framework.md @@ -1054,4 +1054,4 @@ This design provides a comprehensive testing framework for `azd app` that: 5. **Scales efficiently** with parallel execution and caching 6. **Maintains security** through proper input validation -The implementation follows the existing patterns in the codebase and integrates seamlessly with the command orchestration system. +The implementation follows the existing patterns in the codebase and integrates with the command orchestration system. diff --git a/cli/docs/dev/todo.md b/cli/docs/dev/todo.md index 209b1d8d4..d1bef9752 100644 --- a/cli/docs/dev/todo.md +++ b/cli/docs/dev/todo.md @@ -39,7 +39,7 @@ Using `DefaultAzureCredential` which relies on Azure CLI credentials from `azd a **Impact** - Azure Logs feature must use `DefaultAzureCredential` instead of native extension auth - Inconsistent credential handling between ARM calls and Log Analytics calls -- Cannot leverage azd's token caching/refresh for non-ARM APIs +- Cannot use azd's token caching/refresh for non-ARM APIs **Related Files** - `cli/src/internal/azure/credentials.go` - `NewLogAnalyticsCredential()` workaround @@ -75,7 +75,7 @@ See [CLI Output Styling Research](../research/cli-output-styling.md) for detaile 5. **Box Messages**: Use lipgloss borders for important notices **Implementation Phases** -- Phase 1: Leverage existing lipgloss for badges and boxes (quick wins) +- Phase 1: Use existing lipgloss for badges and boxes (quick wins) - Phase 2: Add go-pretty and spinner libraries - Phase 3: Standardize color palette and refactor output functions diff --git a/cli/docs/features/azure-logs.md b/cli/docs/features/azure-logs.md index 2862c4949..984cae7de 100644 --- a/cli/docs/features/azure-logs.md +++ b/cli/docs/features/azure-logs.md @@ -272,7 +272,7 @@ Every step provides copy-paste code snippets: ### Integration Points -The setup guide integrates seamlessly with other dashboard features: +The setup guide works with other dashboard features: - **ModeToggle**: Triggers setup guide when switching to Azure mode - **ConsoleView**: Shows "Setup Guide" button in Azure log errors diff --git a/cli/go.mod b/cli/go.mod index c252ee424..0f539116e 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,6 +1,6 @@ module github.com/jongio/azd-app/cli -go 1.26.0 +go 1.26.1 // Local development: use go.work to resolve azd-core, no local replace // CI: azd-core is pinned to a tagged version in go.mod diff --git a/cli/magefile.go b/cli/magefile.go index 94976c8a1..c12a80c9c 100644 --- a/cli/magefile.go +++ b/cli/magefile.go @@ -1185,6 +1185,52 @@ func fmtCheck() error { return nil } +// preflightGofumpt checks that all Go files are formatted with gofumpt (stricter than gofmt). +func preflightGofumpt() error { + if _, err := exec.LookPath("gofumpt"); err != nil { + fmt.Println(" ⚠️ gofumpt not installed — skipping strict format check") + fmt.Println(" Install with: go install mvdan.cc/gofumpt@latest") + return nil + } + output, err := sh.Output("gofumpt", "-l", ".") + if err != nil { + return fmt.Errorf("gofumpt check failed: %w", err) + } + if strings.TrimSpace(output) != "" { + fmt.Println(" Files not formatted with gofumpt:") + for _, f := range strings.Split(strings.TrimSpace(output), "\n") { + fmt.Printf(" • %s\n", f) + } + return fmt.Errorf("code is not gofumpt-formatted. Run 'gofumpt -w .' to fix") + } + fmt.Println(" ✅ Code is gofumpt-formatted") + return nil +} + +// preflightDeadcode checks for unreachable functions using golang.org/x/tools deadcode analyzer. +func preflightDeadcode() error { + if _, err := exec.LookPath("deadcode"); err != nil { + fmt.Println(" ⚠️ deadcode not installed — skipping dead code check") + fmt.Println(" Install with: go install golang.org/x/tools/cmd/deadcode@latest") + return nil + } + output, err := sh.Output("deadcode", "-test", "./...") + if err != nil { + fmt.Println(" ⚠️ Dead code found:") + fmt.Println(output) + // Non-fatal for now — report but don't fail + fmt.Println(" ⚠️ Dead code check completed with findings (non-fatal)") + return nil + } + if strings.TrimSpace(output) != "" { + fmt.Println(" ⚠️ Potential dead code found:") + fmt.Println(output) + } else { + fmt.Println(" ✅ No dead code detected") + } + return nil +} + // dashboardInstall runs pnpm install for the dashboard project. func dashboardInstall() error { return runQuiet("pnpm", "install", "--dir", dashboardDir) @@ -1216,6 +1262,23 @@ func quietLint() error { fmt.Sprintf("--concurrency=%d", coresPerSlot)) } +// preflightCrossGOOSLint runs golangci-lint with GOOS=linux to catch cross-platform issues. +func preflightCrossGOOSLint() error { + if runtime.GOOS == "linux" { + fmt.Println(" ⏭️ Already on Linux — skipping cross-OS lint") + return nil + } + cmd := exec.Command("golangci-lint", "run", "./...") + cmd.Env = append(os.Environ(), "GOOS=linux") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("cross-OS lint (GOOS=linux) failed: %w", err) + } + fmt.Println(" ✅ Cross-OS lint passed (GOOS=linux)") + return nil +} + // quietModTidy runs go mod tidy and verifies no changes, with captured output. func quietModTidy() error { goModBefore, err := fileHash("go.mod") @@ -1726,6 +1789,7 @@ func Preflight() error { return nil })) g.run(preflightStep("Checking format (no rewrite)", fmtCheck)) + g.run(preflightStep("Checking gofumpt formatting", preflightGofumpt)) g.run(preflightStep("Tidying go.mod and go.sum", quietModTidy, modTidyDone)) g.run(preflightStep("Installing dashboard deps", dashboardInstall, dashInstall)) g.run(preflightStep("Installing website deps", websiteInstall, webInstall)) @@ -1757,6 +1821,8 @@ func Preflight() error { // === After dashboard build: Go tests need go:embed dist from dashboard === g.run(preflightStepAfter([]*gate{dashBuild}, "Running linting (includes vet + staticcheck)", heavy(quietLint))) + g.run(preflightStepAfter([]*gate{dashBuild}, "Checking for dead code", heavy(preflightDeadcode))) + g.run(preflightStepAfter([]*gate{dashBuild}, "Cross-OS lint (GOOS=linux)", preflightCrossGOOSLint)) g.run(preflightStepAfter([]*gate{dashBuild}, "Running tests with coverage", heavy(quietTestCoverage))) // === After mod tidy + dashboard build: build Go binary === @@ -1808,6 +1874,9 @@ func PreflightSequential() error { {"Running go vet", Vet}, {"Running staticcheck", Staticcheck}, {"Running standard linting", Lint}, + {"Checking gofumpt formatting", preflightGofumpt}, + {"Checking for dead code", preflightDeadcode}, + {"Cross-OS lint (GOOS=linux)", preflightCrossGOOSLint}, {"Running quick security scan", runQuickSecurity}, {"Checking for known vulnerabilities", runVulncheck}, {"Running all tests with coverage", TestCoverage}, diff --git a/cli/src/cmd/app/commands/add.go b/cli/src/cmd/app/commands/add.go index b769303b5..51774a041 100644 --- a/cli/src/cmd/app/commands/add.go +++ b/cli/src/cmd/app/commands/add.go @@ -224,7 +224,8 @@ func findAzureYamlForAdd() (string, error) { } func serviceExistsInYaml(path string, serviceName string) (bool, error) { - data, err := os.ReadFile(path) + cleanPath := filepath.Clean(path) + data, err := os.ReadFile(cleanPath) if err != nil { return false, err } @@ -266,7 +267,8 @@ func serviceExistsInYaml(path string, serviceName string) (bool, error) { func addServiceToYaml(path string, serviceName string, def *wellknown.ServiceDefinition) error { // Read file - data, err := os.ReadFile(path) + cleanPath := filepath.Clean(path) + data, err := os.ReadFile(cleanPath) if err != nil { return err } diff --git a/cli/src/cmd/app/commands/core_deps.go b/cli/src/cmd/app/commands/core_deps.go index 9860d7f1d..615038e23 100644 --- a/cli/src/cmd/app/commands/core_deps.go +++ b/cli/src/cmd/app/commands/core_deps.go @@ -107,7 +107,7 @@ func (di *DependencyInstaller) InstallAllFiltered() ([]InstallResult, error) { // installNodeProjectList installs dependencies for a list of Node.js projects. func (di *DependencyInstaller) installNodeProjectList(nodeProjects []types.NodeProject) []InstallResult { - var results []InstallResult + results := make([]InstallResult, 0, len(nodeProjects)) for _, nodeProject := range nodeProjects { result := di.installProject("node", nodeProject.Dir, nodeProject.PackageManager, func() error { return installer.InstallNodeDependencies(nodeProject) @@ -119,7 +119,7 @@ func (di *DependencyInstaller) installNodeProjectList(nodeProjects []types.NodeP // installPythonProjectList installs dependencies for a list of Python projects. func (di *DependencyInstaller) installPythonProjectList(pythonProjects []types.PythonProject) []InstallResult { - var results []InstallResult + results := make([]InstallResult, 0, len(pythonProjects)) for _, pyProject := range pythonProjects { result := di.installProject("python", pyProject.Dir, pyProject.PackageManager, func() error { return installer.SetupPythonVirtualEnv(pyProject) @@ -131,7 +131,7 @@ func (di *DependencyInstaller) installPythonProjectList(pythonProjects []types.P // installDotnetProjectList installs dependencies for a list of .NET projects. func (di *DependencyInstaller) installDotnetProjectList(dotnetProjects []types.DotnetProject) []InstallResult { - var results []InstallResult + results := make([]InstallResult, 0, len(dotnetProjects)) for _, dotnetProject := range dotnetProjects { result := di.installProject("dotnet", filepath.Dir(dotnetProject.Path), "dotnet", func() error { return installer.RestoreDotnetProject(dotnetProject) @@ -155,7 +155,7 @@ func (di *DependencyInstaller) installNodeProjects() ([]InstallResult, error) { cliout.Step("📦", "Found %s Node.js project(s)", cliout.Count(len(nodeProjects))) } - var results []InstallResult + results := make([]InstallResult, 0, len(nodeProjects)) for _, nodeProject := range nodeProjects { result := di.installProject("node", nodeProject.Dir, nodeProject.PackageManager, func() error { return installer.InstallNodeDependencies(nodeProject) @@ -181,7 +181,7 @@ func (di *DependencyInstaller) installPythonProjects() ([]InstallResult, error) cliout.Step("🐍", "Found %s Python project(s)", cliout.Count(len(pythonProjects))) } - var results []InstallResult + results := make([]InstallResult, 0, len(pythonProjects)) for _, pyProject := range pythonProjects { result := di.installProject("python", pyProject.Dir, pyProject.PackageManager, func() error { return installer.SetupPythonVirtualEnv(pyProject) @@ -207,7 +207,7 @@ func (di *DependencyInstaller) installDotnetProjects() ([]InstallResult, error) cliout.Step("🔷", "Found %s .NET project(s)", cliout.Count(len(dotnetProjects))) } - var results []InstallResult + results := make([]InstallResult, 0, len(dotnetProjects)) for _, dotnetProject := range dotnetProjects { result := InstallResult{ Type: "dotnet", diff --git a/cli/src/cmd/app/commands/core_helpers.go b/cli/src/cmd/app/commands/core_helpers.go index 2e437c3a1..0ee08db9b 100644 --- a/cli/src/cmd/app/commands/core_helpers.go +++ b/cli/src/cmd/app/commands/core_helpers.go @@ -337,7 +337,10 @@ func cleanDependencies(nodeProjects []types.NodeProject, pythonProjects []types. // Returns an error if removal fails. func cleanDirectory(path string) error { if _, err := os.Stat(path); err != nil { - return nil // Directory doesn't exist, nothing to clean + if os.IsNotExist(err) { + return nil + } + return err } // Validate that we're only cleaning expected dependency directories diff --git a/cli/src/cmd/app/commands/deps.go b/cli/src/cmd/app/commands/deps.go index ad07805d6..16ccc483f 100644 --- a/cli/src/cmd/app/commands/deps.go +++ b/cli/src/cmd/app/commands/deps.go @@ -156,6 +156,7 @@ func (e *depsExecutor) handleNoProjectsCase(searchRoot string) error { } // GetDepsOptions is a legacy getter function for backward compatibility. +// // Deprecated: Use executor pattern instead. func GetDepsOptions() *DepsOptions { depsOptionsMutex.RLock() diff --git a/cli/src/cmd/app/commands/generate.go b/cli/src/cmd/app/commands/generate.go index af539527c..73240f04d 100644 --- a/cli/src/cmd/app/commands/generate.go +++ b/cli/src/cmd/app/commands/generate.go @@ -2,6 +2,7 @@ package commands import ( + "context" "fmt" "os" "os/exec" @@ -41,6 +42,13 @@ type GenerateResult struct { Skipped int // Number of existing reqs preserved } +const ( + langPython = "python" + langDotnet = "dotnet" + pkgPNPM = "pnpm" + pkgPoetry = "poetry" +) + // runGenerate is the main entry point for the generate command. func runGenerate(config GenerateConfig) error { cliout.CommandHeader("reqs --generate", "Generate requirements from project") @@ -110,7 +118,7 @@ func runGenerate(config GenerateConfig) error { } // detectProjectReqs scans the project directory for all dependencies. -func detectProjectReqs(projectDir string) ([]DetectedRequirement, error) { +func detectProjectReqs(projectDir string) ([]DetectedRequirement, error) { //nolint:unparam // return value kept for future use/interface conformance var requirements []DetectedRequirement foundSources := make(map[string]bool) @@ -186,9 +194,9 @@ func detectProjectReqs(projectDir string) ([]DetectedRequirement, error) { foundSources["Logic Apps Standard"] = true case "nodejs": foundSources["Node.js Functions"] = true - case "python": + case langPython: foundSources["Python Functions"] = true - case "dotnet": + case langDotnet: foundSources[".NET Functions"] = true case "java": foundSources["Java Functions"] = true @@ -220,7 +228,7 @@ func detectProjectReqs(projectDir string) ([]DetectedRequirement, error) { requirements = append(requirements, req) } } - case "python": + case langPython: // Python already detected above if requirements.txt exists // But we should ensure it's added for Functions projects if !hasPythonProject(projectDir) { @@ -228,7 +236,7 @@ func detectProjectReqs(projectDir string) ([]DetectedRequirement, error) { requirements = append(requirements, req) } } - case "dotnet": + case langDotnet: // .NET already detected above if .csproj exists // But we should ensure it's added for Functions projects if !hasDotnetProject(projectDir) { @@ -348,7 +356,7 @@ func detectNodePackageManager(projectDir string) DetectedRequirement { } func detectPython(_ string) DetectedRequirement { - return detectToolWithSource("python", "requirements.txt or pyproject.toml", false) + return detectToolWithSource(langPython, "requirements.txt or pyproject.toml", false) } func detectPythonPackageManager(projectDir string) DetectedRequirement { @@ -358,7 +366,7 @@ func detectPythonPackageManager(projectDir string) DetectedRequirement { } func detectDotnet(_ string) DetectedRequirement { - return detectToolWithSource("dotnet", ".csproj or .sln", false) + return detectToolWithSource(langDotnet, ".csproj or .sln", false) } func detectAspire(_ string) DetectedRequirement { @@ -459,7 +467,7 @@ func getToolVersion(toolName string) (string, error) { // Execute version command directly to capture output // #nosec G204 -- Command and args come from toolRegistry which is a controlled map - cmd := exec.Command(toolConfig.Command, toolConfig.Args...) + cmd := exec.CommandContext(context.Background(), toolConfig.Command, toolConfig.Args...) output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("tool not installed: %s", toolName) @@ -531,17 +539,17 @@ func normalizeVersion(installedVersion string, toolName string) string { parts := strings.Split(installedVersion, ".") switch toolName { - case "node", "dotnet", "go", "rust", "docker", "git": + case "node", langDotnet, "go", "rust", "docker", "git": // Major version only: "22.3.0" -> "22.0.0" if len(parts) >= 1 { return parts[0] + ".0.0" } - case "python": + case langPython: // Major.Minor version: "3.12.5" -> "3.12.0" if len(parts) >= 2 { return parts[0] + "." + parts[1] + ".0" } - case "pnpm", "npm", "yarn", "poetry", "uv", "pip", "pipenv": + case pkgPNPM, "npm", "yarn", pkgPoetry, "uv", "pip", "pipenv": // Major version for package managers: "9.1.4" -> "9.0.0" if len(parts) >= 1 { return parts[0] + ".0.0" @@ -583,13 +591,13 @@ func displayDetectedDependencies(requirements []DetectedRequirement) { if req.Name == "node" { // Look for package manager in other requirements for _, r := range requirements { - if r.Name == "pnpm" || r.Name == "yarn" || r.Name == "npm" { + if r.Name == pkgPNPM || r.Name == "yarn" || r.Name == "npm" { pkgMgr = r.Name break } } } - if req.Name == "node" || req.Name == "npm" || req.Name == "pnpm" || req.Name == "yarn" { + if req.Name == "node" || req.Name == "npm" || req.Name == pkgPNPM || req.Name == "yarn" { sources[fmt.Sprintf("Node.js project (%s)", pkgMgr)] = true } } else if strings.Contains(req.Source, "AppHost.cs") { @@ -602,15 +610,15 @@ func displayDetectedDependencies(requirements []DetectedRequirement) { sources["Docker configuration"] = true } else if strings.Contains(req.Source, "requirements.txt") || strings.Contains(req.Source, "pyproject.toml") { pkgMgr := req.Name - if req.Name == "python" { + if req.Name == langPython { for _, r := range requirements { - if r.Name == "poetry" || r.Name == "uv" || r.Name == "pip" || r.Name == "pipenv" { + if r.Name == pkgPoetry || r.Name == "uv" || r.Name == "pip" || r.Name == "pipenv" { pkgMgr = r.Name break } } } - if req.Name == "python" || req.Name == "pip" || req.Name == "poetry" || req.Name == "uv" || req.Name == "pipenv" { + if req.Name == langPython || req.Name == "pip" || req.Name == pkgPoetry || req.Name == "uv" || req.Name == "pipenv" { sources[fmt.Sprintf("Python project (%s)", pkgMgr)] = true } } @@ -651,9 +659,9 @@ func displayDetectedReqs(reqs []DetectedRequirement) { if req.InstalledVersion == "" { cliout.ItemError("%s: NOT INSTALLED", req.Name) switch req.Name { - case "pnpm": + case pkgPNPM: cliout.Item(" Install: npm install -g pnpm") - case "poetry": + case pkgPoetry: cliout.Item(" Install: curl -sSL https://install.python-poetry.org | python3 -") case "uv": cliout.Item(" Install: curl -LsSf https://astral.sh/uv/install.sh | sh") @@ -732,7 +740,7 @@ func mergeReqs(azureYamlPath string, detected []DetectedRequirement) (int, int, existingCount := len(azureYaml.Reqs) // Convert detected requirements to generic map format for yamlutil - var items []map[string]interface{} + items := make([]map[string]interface{}, 0, len(detected)) for _, det := range detected { item := map[string]interface{}{ "name": det.Name, @@ -771,26 +779,27 @@ func formatReqItem(item map[string]interface{}, arrayIndent string) string { var builder strings.Builder // Array item with Name - builder.WriteString(arrayIndent) - builder.WriteString("- name: ") - builder.WriteString(item["name"].(string)) - builder.WriteString("\n") + _, _ = builder.WriteString(arrayIndent) + _, _ = builder.WriteString("- name: ") + name, _ := item["name"].(string) + _, _ = builder.WriteString(name) + _, _ = builder.WriteString("\n") // MinVersion (quoted) - builder.WriteString(arrayIndent) - builder.WriteString(" minVersion: ") - minVersion := item["minVersion"].(string) + _, _ = builder.WriteString(arrayIndent) + _, _ = builder.WriteString(" minVersion: ") + minVersion, _ := item["minVersion"].(string) if strings.HasPrefix(minVersion, `"`) { - builder.WriteString(minVersion) + _, _ = builder.WriteString(minVersion) } else { - builder.WriteString(`"` + minVersion + `"`) + _, _ = builder.WriteString(`"` + minVersion + `"`) } - builder.WriteString("\n") + _, _ = builder.WriteString("\n") // CheckRunning (if present) if checkRunning, ok := item["checkRunning"].(bool); ok && checkRunning { - builder.WriteString(arrayIndent) - builder.WriteString(" checkRunning: true\n") + _, _ = builder.WriteString(arrayIndent) + _, _ = builder.WriteString(" checkRunning: true\n") } return builder.String() diff --git a/cli/src/cmd/app/commands/health.go b/cli/src/cmd/app/commands/health.go index 1da734cf2..6b12e9696 100644 --- a/cli/src/cmd/app/commands/health.go +++ b/cli/src/cmd/app/commands/health.go @@ -269,7 +269,7 @@ func validateHealthFlags() error { if healthStream && healthInterval <= healthTimeout { return fmt.Errorf("interval (%v) must be greater than timeout (%v) in streaming mode", healthInterval, healthTimeout) } - if healthOutput != "text" && healthOutput != "json" && healthOutput != "table" { + if healthOutput != "text" && healthOutput != jsonOutputVal && healthOutput != "table" { return fmt.Errorf("invalid output format: must be 'text', 'json', or 'table'") } // Validate metrics port is in valid range @@ -300,7 +300,7 @@ func parseServiceFilter(serviceStr string) []string { } // setupSignalHandler sets up signal handling for graceful shutdown -// The goroutine will exit when either a signal is received or the context is cancelled +// The goroutine will exit when either a signal is received or the context is canceled func setupSignalHandler(ctx context.Context, cancel context.CancelFunc) { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) @@ -317,7 +317,7 @@ func setupSignalHandler(ctx context.Context, cancel context.CancelFunc) { fmt.Fprintf(os.Stderr, "\nReceived signal %v, shutting down gracefully...\n", sig) cancel() case <-ctx.Done(): - // Context cancelled (normal exit) - just clean up + // Context canceled (normal exit) - just clean up } }() } @@ -375,7 +375,7 @@ func runStreamingMode(ctx context.Context, monitor *healthcheck.HealthMonitor, s case <-ticker.C: if err := performStreamCheck(ctx, monitor, serviceFilter, &checkCount, &prevReport, isTTY); err != nil { if ctx.Err() != nil { - return nil // Context cancelled, normal shutdown + return nil // Context canceled, normal shutdown } return err } @@ -420,7 +420,7 @@ func performStreamCheck(ctx context.Context, monitor *healthcheck.HealthMonitor, func displayHealthReport(report *healthcheck.HealthReport) error { switch healthOutput { - case "json": + case jsonOutputVal: return displayJSONReport(report) case "table": return displayTableReport(report) @@ -507,7 +507,7 @@ func displayTableReport(report *healthcheck.HealthReport) error { if result.ResponseTime > 0 { response = fmt.Sprintf("%dms", result.ResponseTime.Milliseconds()) } else if result.Error != "" { - response = "error" + response = statusError } fmt.Printf("│ %-12s │ %-9s │ %-9s │ %-32s │ %-8s │\n", diff --git a/cli/src/cmd/app/commands/health_e2e_test.go b/cli/src/cmd/app/commands/health_e2e_test.go index f23f0ce76..a0175909e 100644 --- a/cli/src/cmd/app/commands/health_e2e_test.go +++ b/cli/src/cmd/app/commands/health_e2e_test.go @@ -106,7 +106,7 @@ func TestHealthCommandE2E_FullWorkflow(t *testing.T) { time.Sleep(constants.TestShortSleepDuration) if runtime.GOOS == "windows" { // On Windows, we may need to force kill - _ = exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", runCmd.Process.Pid)).Run() + _ = exec.CommandContext(context.Background(), "taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", runCmd.Process.Pid)).Run() } _ = runCmd.Process.Kill() _ = runCmd.Wait() diff --git a/cli/src/cmd/app/commands/info.go b/cli/src/cmd/app/commands/info.go index cca0c7fdc..769d5143b 100644 --- a/cli/src/cmd/app/commands/info.go +++ b/cli/src/cmd/app/commands/info.go @@ -18,6 +18,13 @@ var ( infoAll bool ) +const ( + statusUnknown = "unknown" + statusRunning = "running" + statusStopped = "stopped" + statusError = "error" +) + // NewInfoCommand creates the info command. func NewInfoCommand() *cobra.Command { cmd := &cobra.Command{ @@ -130,8 +137,8 @@ func printInfoDefault(projectDir string, services []*serviceinfo.ServiceInfo, az } // Get status and health from Local (with defaults if Local is nil) - status := "unknown" - health := "unknown" + status := statusUnknown + health := statusUnknown if svc.Local != nil { status = svc.Local.Status health = svc.Local.Health @@ -193,7 +200,7 @@ func printInfoDefault(projectDir string, services []*serviceinfo.ServiceInfo, az } // Runtime info (only if service is running) - if svc.Local != nil && svc.Local.Status == "running" { + if svc.Local != nil && svc.Local.Status == statusRunning { if svc.Local.Port > 0 { cliout.Label(" Port", fmt.Sprintf("%d", svc.Local.Port)) } @@ -211,7 +218,7 @@ func printInfoDefault(projectDir string, services []*serviceinfo.ServiceInfo, az // Status and health (from Local) if svc.Local != nil { cliout.Label(" Status", formatStatus(svc.Local.Status)) - if svc.Local.Health != "unknown" { + if svc.Local.Health != statusUnknown { cliout.Label(" Health", formatHealth(svc.Local.Health)) } } @@ -262,15 +269,15 @@ func getServiceEnvironmentVars(serviceName string, azureEnv map[string]string) m // Valid statuses: "running", "starting", "error", "stopped", "not-running", "unknown" func formatStatus(status string) string { switch status { - case "running": + case statusRunning: return colorGreen + status + colorReset case "starting": return colorYellow + status + colorReset - case "error": + case statusError: return colorRed + status + colorReset - case "stopped", "not-running": + case statusStopped, "not-running": return colorGray + status + colorReset - case "unknown": + case statusUnknown: return colorYellow + status + colorReset default: return status @@ -284,7 +291,7 @@ func formatHealth(health string) string { return colorGreen + health + colorReset case "unhealthy": return colorRed + health + colorReset - case "unknown": + case statusUnknown: return colorYellow + health + colorReset default: return health @@ -325,11 +332,11 @@ func formatInfoDuration(d time.Duration) string { // Valid statuses: "running", "starting", "error", "stopped", "not-running", "unknown" func getInfoStatusIcon(status, health string) string { // Running and healthy - green check - if status == "running" && health == "healthy" { + if status == statusRunning && health == "healthy" { return colorGreen + "✓" + colorReset } // Running but unhealthy - red X - if status == "running" && health == "unhealthy" { + if status == statusRunning && health == "unhealthy" { return colorRed + "✗" + colorReset } // Starting - yellow circle @@ -337,11 +344,11 @@ func getInfoStatusIcon(status, health string) string { return colorYellow + "○" + colorReset } // Error status - red X - if status == "error" { + if status == statusError { return colorRed + "✗" + colorReset } // Stopped or not-running - gray dot - if status == "stopped" || status == "not-running" { + if status == statusStopped || status == "not-running" { return colorGray + "●" + colorReset } // Unknown or any other status - yellow question mark diff --git a/cli/src/cmd/app/commands/logs.go b/cli/src/cmd/app/commands/logs.go index 0235d60b2..8c7ff8315 100644 --- a/cli/src/cmd/app/commands/logs.go +++ b/cli/src/cmd/app/commands/logs.go @@ -52,6 +52,8 @@ const ( // filterCapacityEstimate is the estimated match rate for level filtering. // Assumes ~25% of logs match a specific level filter. filterCapacityEstimate = 4 + logLevelWarn = "warn" + logLevelDebug = "debug" ) // DashboardClient defines the interface for dashboard operations needed by logs. @@ -61,7 +63,7 @@ type DashboardClient interface { GetServices(ctx context.Context) ([]*serviceinfo.ServiceInfo, error) StreamLogs(ctx context.Context, serviceName string, logs chan<- service.LogEntry) error GetAzureLogs(ctx context.Context, services []string, tail int, since time.Time) ([]service.LogEntry, error) - GetAzureStatus(ctx context.Context) (*service.AzureStatus, error) + GetAzureStatus(ctx context.Context) (*service.AzureStatus, error) //nolint:staticcheck // backward-compatible API StreamAzureLogs(ctx context.Context, logs chan<- service.LogEntry) error } @@ -332,7 +334,7 @@ func (e *logsExecutor) execute(ctx context.Context, args []string) error { cliout.Warning("%s", w) } - if e.opts.source == "azure" && !e.opts.follow { + if e.opts.source == string(LogSourceAzure) && !e.opts.follow { isEmpty := (!collected.HasContext && len(collected.Entries) == 0) || (collected.HasContext && len(collected.EntriesWithContext) == 0) if isEmpty { @@ -354,13 +356,13 @@ func (e *logsExecutor) execute(ctx context.Context, args []string) error { // Display logs if collected.HasContext { - if e.opts.format == "json" { + if e.opts.format == jsonOutputVal { displayLogsWithContextJSON(collected.EntriesWithContext, outputWriter) } else { displayLogsWithContextText(collected.EntriesWithContext, outputWriter, e.opts.timestamps, e.opts.noColor) } } else { - if e.opts.format == "json" { + if e.opts.format == jsonOutputVal { displayLogsJSON(collected.Entries, outputWriter) } else { displayLogsText(collected.Entries, outputWriter, e.opts.timestamps, e.opts.noColor) @@ -494,12 +496,12 @@ func (e *logsExecutor) collect(ctx context.Context, args []string) (*CollectedLo // Get logs based on source option var logs []service.LogEntry switch e.opts.source { - case "azure": + case string(LogSourceAzure): logs, err = e.collectAzureLogs(ctx, cwd, dashboardClient, targetServices, sinceTime, result) if err != nil { return nil, err } - case "all": + case string(LogSourceAll): logs, err = e.collectAllLogsQuiet(ctx, cwd, dashboardClient, targetServices, logManager, sinceTime, result) if err != nil { return nil, fmt.Errorf("failed to collect logs: %w", err) @@ -555,7 +557,7 @@ func (e *logsExecutor) collect(ctx context.Context, args []string) (*CollectedLo // collectAllLogsQuiet collects logs from both local and Azure sources, // appending warnings to the CollectedLogs result instead of calling cliout. -func (e *logsExecutor) collectAllLogsQuiet(ctx context.Context, cwd string, dashboardClient DashboardClient, targetServices []string, logManager LogManagerInterface, sinceTime time.Time, result *CollectedLogs) ([]service.LogEntry, error) { +func (e *logsExecutor) collectAllLogsQuiet(ctx context.Context, cwd string, dashboardClient DashboardClient, targetServices []string, logManager LogManagerInterface, sinceTime time.Time, result *CollectedLogs) ([]service.LogEntry, error) { //nolint:unparam // return value kept for future use/interface conformance var allLogs []service.LogEntry // Collect local logs (requires dashboard) @@ -647,7 +649,7 @@ func (e *logsExecutor) setupOutputWriter() (io.Writer, func(), error) { // Ensure parent directory exists outputDir := filepath.Dir(e.opts.file) if outputDir != "" && outputDir != "." { - if err := os.MkdirAll(outputDir, 0755); err != nil { + if err := os.MkdirAll(outputDir, 0750); err != nil { return nil, nil, fmt.Errorf("failed to create output directory: %w", err) } } @@ -866,11 +868,11 @@ func logLevelToString(level service.LogLevel) string { case service.LogLevelInfo: return "info" case service.LogLevelWarn: - return "warn" + return logLevelWarn case service.LogLevelError: - return "error" + return statusError case service.LogLevelDebug: - return "debug" + return logLevelDebug default: return "info" } @@ -958,9 +960,9 @@ func (e *logsExecutor) shouldDisplayEntry(entry service.LogEntry, levelFilter se func (e *logsExecutor) followLogs(ctx context.Context, projectDir string, logManager LogManagerInterface, dashboardClient DashboardClient, serviceFilter []string, levelFilter service.LogLevel, logFilter *service.LogFilter, outputWriter io.Writer) error { // Handle source-specific follow modes switch e.opts.source { - case "azure": + case string(LogSourceAzure): return e.followAzureLogs(ctx, dashboardClient, projectDir, serviceFilter, levelFilter, logFilter, outputWriter) - case "all": + case string(LogSourceAll): return e.followAllLogs(ctx, projectDir, logManager, dashboardClient, serviceFilter, levelFilter, logFilter, outputWriter) default: // "local" return e.followLocalLogs(ctx, projectDir, logManager, dashboardClient, serviceFilter, levelFilter, logFilter, outputWriter) @@ -968,7 +970,7 @@ func (e *logsExecutor) followLogs(ctx context.Context, projectDir string, logMan } // followLocalLogs subscribes to local log streams and displays them. -func (e *logsExecutor) followLocalLogs(ctx context.Context, projectDir string, logManager LogManagerInterface, dashboardClient DashboardClient, serviceFilter []string, levelFilter service.LogLevel, logFilter *service.LogFilter, outputWriter io.Writer) error { +func (e *logsExecutor) followLocalLogs(ctx context.Context, _ string, logManager LogManagerInterface, dashboardClient DashboardClient, serviceFilter []string, levelFilter service.LogLevel, logFilter *service.LogFilter, outputWriter io.Writer) error { // Try in-memory subscriptions first subscriptions := make(map[string]chan service.LogEntry) @@ -1009,7 +1011,7 @@ func (e *logsExecutor) followLogsViaDashboard(ctx context.Context, dashboardClie cliout.Info("Streaming logs from dashboard...") - // Create context for streaming that can be cancelled + // Create context for streaming that can be canceled streamCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -1056,7 +1058,7 @@ func (e *logsExecutor) followLogsViaDashboard(ctx context.Context, dashboardClie } // Display log entry - if e.opts.format == "json" { + if e.opts.format == jsonOutputVal { displayLogsJSON([]service.LogEntry{entry}, outputWriter) } else { displayLogsText([]service.LogEntry{entry}, outputWriter, e.opts.timestamps, e.opts.noColor) @@ -1147,7 +1149,7 @@ func (e *logsExecutor) followLogsInMemory(subscriptions map[string]chan service. } // Display log entry - if e.opts.format == "json" { + if e.opts.format == jsonOutputVal { displayLogsJSON([]service.LogEntry{entry}, outputWriter) } else { displayLogsText([]service.LogEntry{entry}, outputWriter, e.opts.timestamps, e.opts.noColor) @@ -1194,7 +1196,7 @@ func (e *logsExecutor) followAzureLogsViaDashboard(ctx context.Context, dashboar cliout.Info("Streaming Azure logs (polling every 30s)...") - // Create context for streaming that can be cancelled + // Create context for streaming that can be canceled streamCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -1239,7 +1241,7 @@ func (e *logsExecutor) followAzureLogsViaDashboard(ctx context.Context, dashboar } // Display log entry - if e.opts.format == "json" { + if e.opts.format == jsonOutputVal { displayLogsJSON([]service.LogEntry{entry}, outputWriter) } else { displayLogsText([]service.LogEntry{entry}, outputWriter, e.opts.timestamps, e.opts.noColor) @@ -1262,7 +1264,7 @@ func (e *logsExecutor) followAzureLogsViaDashboard(ctx context.Context, dashboar func (e *logsExecutor) followAzureLogsStandalone(ctx context.Context, projectDir string, serviceFilter []string, levelFilter service.LogLevel, logFilter *service.LogFilter, outputWriter io.Writer) error { cliout.Info("Streaming Azure logs (standalone, polling)...") - // Create context for streaming that can be cancelled + // Create context for streaming that can be canceled streamCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -1328,7 +1330,7 @@ func (e *logsExecutor) followAzureLogsStandalone(ctx context.Context, projectDir continue } - if e.opts.format == "json" { + if e.opts.format == jsonOutputVal { displayLogsJSON([]service.LogEntry{entry}, outputWriter) } else { displayLogsText([]service.LogEntry{entry}, outputWriter, e.opts.timestamps, e.opts.noColor) @@ -1348,7 +1350,7 @@ func (e *logsExecutor) followAzureLogsStandalone(ctx context.Context, projectDir } // followAllLogs streams logs from both local and Azure sources. -func (e *logsExecutor) followAllLogs(ctx context.Context, projectDir string, logManager LogManagerInterface, dashboardClient DashboardClient, serviceFilter []string, levelFilter service.LogLevel, logFilter *service.LogFilter, outputWriter io.Writer) error { +func (e *logsExecutor) followAllLogs(ctx context.Context, projectDir string, _ LogManagerInterface, dashboardClient DashboardClient, serviceFilter []string, levelFilter service.LogLevel, logFilter *service.LogFilter, outputWriter io.Writer) error { if dashboardClient == nil { cliout.Warning("Dashboard not running; following Azure logs only.") return e.followAzureLogsStandalone(ctx, projectDir, serviceFilter, levelFilter, logFilter, outputWriter) @@ -1362,7 +1364,7 @@ func (e *logsExecutor) followAllLogs(ctx context.Context, projectDir string, log cliout.Info("Streaming logs from local and Azure sources...") - // Create context for streaming that can be cancelled + // Create context for streaming that can be canceled streamCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -1452,7 +1454,7 @@ func (e *logsExecutor) followAllLogs(ctx context.Context, projectDir string, log } // Display log entry - if e.opts.format == "json" { + if e.opts.format == jsonOutputVal { displayLogsJSON([]service.LogEntry{entry}, outputWriter) } else { displayLogsText([]service.LogEntry{entry}, outputWriter, e.opts.timestamps, e.opts.noColor) @@ -1508,7 +1510,8 @@ func readLogsFromFile(projectDir, serviceName string, tail int, sinceTime time.T // readSingleLogFile reads log entries from a single log file. func readSingleLogFile(logFile, serviceName string, sinceTime time.Time) ([]service.LogEntry, error) { - file, err := os.Open(logFile) + cleanLogFile := filepath.Clean(logFile) + file, err := os.Open(cleanLogFile) if err != nil { return nil, err } @@ -1732,9 +1735,9 @@ func displayLogsWithContextText(logs []LogEntryWithContext, w io.Writer, showTim switch entry.Level { case "error": line.WriteString(colorRed + entry.Message + colorReset) - case "warn": + case logLevelWarn: line.WriteString(colorYellow + entry.Message + colorReset) - case "debug": + case logLevelDebug: line.WriteString(colorGray + entry.Message + colorReset) default: line.WriteString(entry.Message) @@ -1764,11 +1767,11 @@ func parseLogLevel(level string) service.LogLevel { switch strings.ToLower(level) { case "info": return service.LogLevelInfo - case "warn", "warning": + case logLevelWarn, "warning": return service.LogLevelWarn case "error": return service.LogLevelError - case "debug": + case logLevelDebug: return service.LogLevelDebug case "all": return LogLevelAll @@ -1799,6 +1802,7 @@ func filterLogsByLevel(logs []service.LogEntry, level service.LogLevel) []servic // buildLogFilter creates a log filter from options and azure.yaml config. // This is a test helper function that wraps buildLogFilterInternal. +// // Deprecated: Use executor.buildLogFilterInternal directly in new code. func buildLogFilter(cwd string, exclude string, noBuiltins bool) (*service.LogFilter, error) { var customPatterns []string @@ -1848,7 +1852,7 @@ func validateLogsOptions(opts *logsOptions) error { // Validate format switch opts.format { - case "text", "json": + case "text", jsonOutputVal: // Valid formats default: return fmt.Errorf("--format must be 'text' or 'json', got '%s'", opts.format) @@ -1856,7 +1860,7 @@ func validateLogsOptions(opts *logsOptions) error { // Validate level switch strings.ToLower(opts.level) { - case "info", "warn", "warning", "error", "debug", "all": + case "info", logLevelWarn, "warning", "error", logLevelDebug, "all": // Valid levels default: return fmt.Errorf("--level must be one of: info, warn, error, debug, all; got '%s'", opts.level) @@ -1887,12 +1891,12 @@ func validateLogsOptions(opts *logsOptions) error { // Validate source switch strings.ToLower(opts.source) { - case "local", "azure", "all": + case string(LogSourceLocal), string(LogSourceAzure), string(LogSourceAll): // Valid sources - normalize to lowercase opts.source = strings.ToLower(opts.source) case "": // Default to local if not specified - opts.source = "local" + opts.source = string(LogSourceLocal) default: return fmt.Errorf("--source must be 'local', 'azure', or 'all'; got '%s'", opts.source) } diff --git a/cli/src/cmd/app/commands/logs_executor_test.go b/cli/src/cmd/app/commands/logs_executor_test.go index f039cd6c0..9c30c65c7 100644 --- a/cli/src/cmd/app/commands/logs_executor_test.go +++ b/cli/src/cmd/app/commands/logs_executor_test.go @@ -28,7 +28,7 @@ type mockDashboardClient struct { logEntries []service.LogEntry azureLogs []service.LogEntry getAzureLogsErr error - azureStatus *service.AzureStatus + azureStatus *service.AzureStatus //nolint:staticcheck // required by interface getAzureStatusErr error } @@ -60,12 +60,12 @@ func (m *mockDashboardClient) GetAzureLogs(ctx context.Context, services []strin return m.azureLogs, nil } -func (m *mockDashboardClient) GetAzureStatus(ctx context.Context) (*service.AzureStatus, error) { +func (m *mockDashboardClient) GetAzureStatus(ctx context.Context) (*service.AzureStatus, error) { //nolint:staticcheck // required by interface if m.getAzureStatusErr != nil { return nil, m.getAzureStatusErr } if m.azureStatus == nil { - return &service.AzureStatus{Enabled: false}, nil + return &service.AzureStatus{Enabled: false}, nil //nolint:staticcheck // required by interface } return m.azureStatus, nil } diff --git a/cli/src/cmd/app/commands/mcp.go b/cli/src/cmd/app/commands/mcp.go index 47d17cbd2..05e72a762 100644 --- a/cli/src/cmd/app/commands/mcp.go +++ b/cli/src/cmd/app/commands/mcp.go @@ -78,7 +78,7 @@ func runMCPServe(cmd *cobra.Command, args []string) error { } // runMCPServer implements the MCP server logic -func runMCPServer(ctx context.Context) error { +func runMCPServer(_ context.Context) error { // System instructions to guide AI on how to use the tools // This server is part of the azd extension framework and provides runtime operations instructions := `This MCP server is provided by the azd app extension and focuses on runtime operations for azd projects. @@ -149,9 +149,9 @@ func executeAzdAppCommand(ctx context.Context, command string, args []string) (m // executeAzdAppCommandWithTimeout executes an azd app command with a custom timeout func executeAzdAppCommandWithTimeout(ctx context.Context, command string, args []string, timeout time.Duration) (map[string]interface{}, error) { - // Check if context is already cancelled + // Check if context is already canceled if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("command cancelled before execution: %w", err) + return nil, fmt.Errorf("command canceled before execution: %w", err) } cmdArgs := append([]string{command}, args...) @@ -169,9 +169,9 @@ func executeAzdAppCommandWithTimeout(ctx context.Context, command string, args [ output, err := cmd.CombinedOutput() if err != nil { - // Check if parent context was cancelled + // Check if parent context was canceled if errors.Is(ctx.Err(), context.Canceled) { - return nil, fmt.Errorf("command cancelled: %w", ctx.Err()) + return nil, fmt.Errorf("command canceled: %w", ctx.Err()) } // Check if command context timed out if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { diff --git a/cli/src/cmd/app/commands/mcp_resources.go b/cli/src/cmd/app/commands/mcp_resources.go index c0580fb6c..54aed0c28 100644 --- a/cli/src/cmd/app/commands/mcp_resources.go +++ b/cli/src/cmd/app/commands/mcp_resources.go @@ -25,7 +25,7 @@ func newAzureYamlResource() server.ServerResource { Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { // Check context if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("request cancelled: %w", err) + return nil, fmt.Errorf("request canceled: %w", err) } // Get and validate project directory @@ -88,7 +88,7 @@ func newServiceConfigResource() server.ServerResource { Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { // Check context if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("request cancelled: %w", err) + return nil, fmt.Errorf("request canceled: %w", err) } // Get service configurations from project directory diff --git a/cli/src/cmd/app/commands/mcp_tools.go b/cli/src/cmd/app/commands/mcp_tools.go index 953990610..7947aa331 100644 --- a/cli/src/cmd/app/commands/mcp_tools.go +++ b/cli/src/cmd/app/commands/mcp_tools.go @@ -149,7 +149,7 @@ func handleGetServiceLogs(ctx context.Context, args azdext.ToolArgs) (*mcp.CallT } if ctxErr := ctx.Err(); ctxErr != nil { - return azdext.MCPErrorResult("Request cancelled: %v", ctxErr), nil + return azdext.MCPErrorResult("Request canceled: %v", ctxErr), nil } collectCtx, collectCancel := context.WithTimeout(ctx, defaultCommandTimeout) @@ -243,7 +243,7 @@ func handleGetServiceErrors(ctx context.Context, args azdext.ToolArgs) (*mcp.Cal } if err := ctx.Err(); err != nil { - return azdext.MCPErrorResult("Request cancelled: %v", err), nil + return azdext.MCPErrorResult("Request canceled: %v", err), nil } collectCtx, collectCancel := context.WithTimeout(ctx, defaultCommandTimeout) @@ -354,9 +354,8 @@ func handleRunServices(ctx context.Context, args azdext.ToolArgs) (*mcp.CallTool } // Note: azd app run is interactive and long-running, so we run it in a non-blocking way - // and return information about the command being executed - // The context is intentionally NOT used here because the process should continue running - cmd := exec.Command(azdCommand, append([]string{appSubcommand, "run"}, cmdArgs...)...) + // and return information about the command being executed. + cmd := exec.CommandContext(context.WithoutCancel(ctx), azdCommand, append([]string{appSubcommand, "run"}, cmdArgs...)...) // Create pipes to capture startup errors without blocking stderrPipe, err := cmd.StderrPipe() @@ -575,7 +574,7 @@ func handleInstallDependencies(ctx context.Context, args azdext.ToolArgs) (*mcp. } if ctxErr := ctx.Err(); ctxErr != nil { - return azdext.MCPErrorResult("Request cancelled: %v", ctxErr), nil + return azdext.MCPErrorResult("Request canceled: %v", ctxErr), nil } cmdCtx, cancel := context.WithTimeout(ctx, dependencyInstallTimeout) @@ -585,7 +584,7 @@ func handleInstallDependencies(ctx context.Context, args azdext.ToolArgs) (*mcp. output, err := cmd.CombinedOutput() if err != nil { if errors.Is(ctx.Err(), context.Canceled) { - return azdext.MCPErrorResult("Request was cancelled"), nil + return azdext.MCPErrorResult("Request was canceled"), nil } if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { return azdext.MCPErrorResult("Dependency installation timed out after %v", dependencyInstallTimeout), nil diff --git a/cli/src/cmd/app/commands/notifications.go b/cli/src/cmd/app/commands/notifications.go index 57cc66e95..1f10fe613 100644 --- a/cli/src/cmd/app/commands/notifications.go +++ b/cli/src/cmd/app/commands/notifications.go @@ -181,12 +181,12 @@ func newNotificationsClearCmd() *cobra.Command { case response = <-responseChan: // User provided input case <-ctx.Done(): - fmt.Println("\nCancelled by context") + fmt.Println("\nCanceled by context") return ctx.Err() } if response != "y" && response != "Y" { - fmt.Println("Cancelled") + fmt.Println("Canceled") return nil } diff --git a/cli/src/cmd/app/commands/reqs.go b/cli/src/cmd/app/commands/reqs.go index 60cca9b8b..5a56d2bcc 100644 --- a/cli/src/cmd/app/commands/reqs.go +++ b/cli/src/cmd/app/commands/reqs.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "os" "os/exec" @@ -53,6 +54,11 @@ type AzureYaml struct { Services map[string]ReqsService `yaml:"services,omitempty"` } +const ( + toolDocker = "docker" + osWindows = "windows" +) + // hasContainerServices returns true if any service is a container service. func (a *AzureYaml) hasContainerServices() bool { for _, svc := range a.Services { @@ -69,7 +75,7 @@ func (a *AzureYaml) hasContainerServices() bool { // hasDockerReq returns true if Docker is already in the reqs list. func (a *AzureYaml) hasDockerReq() bool { for _, req := range a.Reqs { - if strings.EqualFold(req.Name, "docker") { + if strings.EqualFold(req.Name, toolDocker) { return true } } @@ -148,8 +154,8 @@ var toolRegistry = map[string]ToolConfig{ Command: "aspire", Args: []string{"--version"}, }, - "docker": { - Command: "docker", + toolDocker: { + Command: toolDocker, Args: []string{"--version"}, VersionField: 2, // "Docker version 28.5.1, build ..." -> take field 2 }, @@ -206,28 +212,28 @@ var toolAliases = map[string]string{ // installURLRegistry maps tool names to their installation page URLs. var installURLRegistry = map[string]string{ - "node": "https://nodejs.org/", - "npm": "https://nodejs.org/", - "pnpm": "https://pnpm.io/installation", - "yarn": "https://yarnpkg.com/getting-started/install", - "python": "https://www.python.org/downloads/", - "pip": "https://www.python.org/downloads/", - "poetry": "https://python-poetry.org/docs/#installation", - "uv": "https://docs.astral.sh/uv/getting-started/installation/", - "pipenv": "https://pipenv.pypa.io/en/latest/installation.html", - "dotnet": "https://dotnet.microsoft.com/download", - "aspire": "https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling", - "docker": "https://www.docker.com/products/docker-desktop", - "git": "https://git-scm.com/downloads", - "go": "https://go.dev/dl/", - "azd": "https://aka.ms/install-azd", - "az": "https://aka.ms/installazurecli", - "air": "https://github.com/air-verse/air#installation", - "func": "https://learn.microsoft.com/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools", - "java": "https://adoptium.net/", - "mvn": "https://maven.apache.org/install.html", - "gradle": "https://gradle.org/install/", - "gh": "https://cli.github.com/", + "node": "https://nodejs.org/", + "npm": "https://nodejs.org/", + "pnpm": "https://pnpm.io/installation", + "yarn": "https://yarnpkg.com/getting-started/install", + "python": "https://www.python.org/downloads/", + "pip": "https://www.python.org/downloads/", + "poetry": "https://python-poetry.org/docs/#installation", + "uv": "https://docs.astral.sh/uv/getting-started/installation/", + "pipenv": "https://pipenv.pypa.io/en/latest/installation.html", + "dotnet": "https://dotnet.microsoft.com/download", + "aspire": "https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling", + toolDocker: "https://www.docker.com/products/docker-desktop", + "git": "https://git-scm.com/downloads", + "go": "https://go.dev/dl/", + "azd": "https://aka.ms/install-azd", + "az": "https://aka.ms/installazurecli", + "air": "https://github.com/air-verse/air#installation", + "func": "https://learn.microsoft.com/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools", + "java": "https://adoptium.net/", + "mvn": "https://maven.apache.org/install.html", + "gradle": "https://gradle.org/install/", + "gh": "https://cli.github.com/", } // NewReqsCommand creates the reqs command. @@ -357,7 +363,7 @@ func (pc *PrerequisiteChecker) Check(prereq Prerequisite) ReqResult { // When Podman is aliased to Docker, skip version comparison since version schemes differ. // Podman uses its own versioning (e.g., 5.7.0) which is not comparable to Docker versions (e.g., 20.10.0). - if isPodman && prereq.Name == "docker" { + if isPodman && prereq.Name == toolDocker { result.Message = "Podman detected (version check skipped)" if !cliout.IsJSON() { cliout.ItemSuccess("%s: %s via Podman (version check skipped)", prereq.Name, version) @@ -445,7 +451,7 @@ func (pc *PrerequisiteChecker) getInstalledVersion(prereq Prerequisite) (install config := pc.getToolConfig(prereq) // #nosec G204 -- Command and args come from toolRegistry or validated azure.yaml prerequisite configuration - cmd := exec.Command(config.Command, config.Args...) + cmd := exec.CommandContext(context.Background(), config.Command, config.Args...) output, err := cmd.CombinedOutput() if err != nil { return false, "", false @@ -506,8 +512,8 @@ func (pc *PrerequisiteChecker) checkIsRunning(prereq Prerequisite) bool { // Default checks for known tools if command == "" { switch prereq.Name { - case "docker": - command = "docker" + case toolDocker: + command = toolDocker args = []string{"ps"} default: // No default running check for this tool @@ -518,7 +524,7 @@ func (pc *PrerequisiteChecker) checkIsRunning(prereq Prerequisite) bool { } // #nosec G204 -- Command and args come from azure.yaml running check configuration or default Docker check - cmd := exec.Command(command, args...) + cmd := exec.CommandContext(context.Background(), command, args...) output, err := cmd.CombinedOutput() // Check exit code @@ -920,7 +926,7 @@ func runReqsFix() error { cliout.Info("ℹ️ Note: Tools may not be available in THIS terminal session") // Provide platform-specific refresh instructions - if runtime.GOOS == "windows" { + if runtime.GOOS == osWindows { cliout.Info(" To refresh PATH in your current PowerShell session, run:") cliout.Info(" %s$env:PATH = [System.Environment]::GetEnvironmentVariable(\"Path\",\"Machine\") + \";\" + [System.Environment]::GetEnvironmentVariable(\"Path\",\"User\")%s", cliout.Dim, cliout.Reset) cliout.Info(" Or simply restart your terminal") diff --git a/cli/src/cmd/app/commands/reqs_fix_integration_test.go b/cli/src/cmd/app/commands/reqs_fix_integration_test.go index b8aab5006..bf9636fd6 100644 --- a/cli/src/cmd/app/commands/reqs_fix_integration_test.go +++ b/cli/src/cmd/app/commands/reqs_fix_integration_test.go @@ -3,6 +3,7 @@ package commands import ( + "context" "fmt" "os" "os/exec" @@ -327,7 +328,7 @@ func TestReqsFixIntegration_CacheClearing(t *testing.T) { // Helper functions -func buildTestTool(t *testing.T, targetDir string) string { +func buildTestTool(t *testing.T, targetDir string) string { //nolint:unparam // return value kept for future use in integration tests t.Helper() if err := os.MkdirAll(targetDir, 0755); err != nil { @@ -347,7 +348,7 @@ func buildTestTool(t *testing.T, targetDir string) string { } exePath := filepath.Join(targetDir, exeName) - cmd := exec.Command("go", "build", "-o", exePath, sourcePath) + cmd := exec.CommandContext(context.Background(), "go", "build", "-o", exePath, sourcePath) cmd.Dir = targetDir output, err := cmd.CombinedOutput() if err != nil { @@ -371,7 +372,7 @@ func addToUserPATH(t *testing.T, dir string) { } // Read current User PATH from registry - cmd := exec.Command("powershell", "-Command", + cmd := exec.CommandContext(context.Background(), "powershell", "-Command", "[Environment]::GetEnvironmentVariable('Path', 'User')") output, err := cmd.Output() if err != nil { @@ -386,7 +387,7 @@ func addToUserPATH(t *testing.T, dir string) { // Add to User PATH newPath := currentPath + ";" + dir - cmd = exec.Command("powershell", "-Command", + cmd = exec.CommandContext(context.Background(), "powershell", "-Command", fmt.Sprintf("[Environment]::SetEnvironmentVariable('Path', '%s', 'User')", newPath)) if err := cmd.Run(); err != nil { t.Fatalf("Failed to add to User PATH: %v", err) @@ -403,7 +404,7 @@ func cleanupUserPATH(t *testing.T, dir string) { } // Read current User PATH - cmd := exec.Command("powershell", "-Command", + cmd := exec.CommandContext(context.Background(), "powershell", "-Command", "[Environment]::GetEnvironmentVariable('Path', 'User')") output, err := cmd.Output() if err != nil { @@ -421,7 +422,7 @@ func cleanupUserPATH(t *testing.T, dir string) { newPath = strings.ReplaceAll(newPath, dir+";", "") newPath = strings.ReplaceAll(newPath, dir, "") - cmd = exec.Command("powershell", "-Command", + cmd = exec.CommandContext(context.Background(), "powershell", "-Command", fmt.Sprintf("[Environment]::SetEnvironmentVariable('Path', '%s', 'User')", newPath)) if err := cmd.Run(); err != nil { t.Logf("Warning: Failed to remove from User PATH: %v", err) diff --git a/cli/src/cmd/app/commands/restart.go b/cli/src/cmd/app/commands/restart.go index 2adb8fde6..ffb426875 100644 --- a/cli/src/cmd/app/commands/restart.go +++ b/cli/src/cmd/app/commands/restart.go @@ -81,7 +81,7 @@ func runRestart(cmd *cobra.Command, args []string) error { return nil } if !confirmBulkOperation(len(servicesToRestart), "restart", restartYes) { - cliout.Info("Operation cancelled") + cliout.Info("Operation canceled") return nil } } else { diff --git a/cli/src/cmd/app/commands/run.go b/cli/src/cmd/app/commands/run.go index 02e2aab66..81c64872c 100644 --- a/cli/src/cmd/app/commands/run.go +++ b/cli/src/cmd/app/commands/run.go @@ -137,7 +137,7 @@ func runServicesFromAzureYaml(ctx context.Context, azureYamlPath string, runtime } // runAzdMode runs services in azd mode with individual service orchestration. -func runAzdMode(ctx context.Context, azureYamlPath, azureYamlDir string) error { +func runAzdMode(_ context.Context, azureYamlPath, azureYamlDir string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current directory: %w", err) @@ -520,7 +520,7 @@ func startDashboardMonitor(ctx context.Context, wg *sync.WaitGroup, dashboardSer cliout.Hint("Press Ctrl+C to stop", "--web to open browser") } - // Block until context is cancelled + // Block until context is canceled <-ctx.Done() }() } @@ -691,7 +691,7 @@ func monitorServiceProcess(ctx context.Context, wg *sync.WaitGroup, serviceName } // Intentionally don't cancel context - other services should continue case <-ctx.Done(): - // Context cancelled by signal - proceed to graceful shutdown + // Context canceled by signal - proceed to graceful shutdown return } } diff --git a/cli/src/cmd/app/commands/service_control.go b/cli/src/cmd/app/commands/service_control.go index dec2b7ea2..67aced4e0 100644 --- a/cli/src/cmd/app/commands/service_control.go +++ b/cli/src/cmd/app/commands/service_control.go @@ -115,7 +115,7 @@ func newErrorResult(serviceName, errMsg string) *ServiceControlResult { } // validateAndGetService validates the service name and retrieves the registry entry. -func (c *ServiceController) validateAndGetService(serviceName string) (*registry.ServiceRegistryEntry, *ServiceControlResult) { +func (c *ServiceController) validateAndGetService(serviceName string) (*registry.ServiceRegistryEntry, *ServiceControlResult) { //nolint:unparam // return value kept for future use/interface conformance if err := security.ValidateServiceName(serviceName, false); err != nil { return nil, newErrorResult(serviceName, err.Error()) } @@ -301,7 +301,7 @@ func (c *ServiceController) performStart(ctx context.Context, entry *registry.Se // Check context before starting expensive operations select { case <-ctx.Done(): - return fmt.Errorf("operation cancelled: %w", ctx.Err()) + return fmt.Errorf("operation canceled: %w", ctx.Err()) default: } @@ -429,7 +429,7 @@ func (c *ServiceController) performStop(ctx context.Context, entry *registry.Ser // Check context before starting select { case <-ctx.Done(): - return fmt.Errorf("operation cancelled: %w", ctx.Err()) + return fmt.Errorf("operation canceled: %w", ctx.Err()) default: } @@ -532,7 +532,7 @@ func printBulkResult(result *BulkServiceControlResult) { // setupContextWithSignalHandling creates a context that cancels on SIGINT/SIGTERM. // Returns the context, cancel function, and a cleanup function that should be deferred. -func setupContextWithSignalHandling() (context.Context, context.CancelFunc, func()) { +func setupContextWithSignalHandling() (context.Context, context.CancelFunc, func()) { //nolint:unparam // return value kept for future use/interface conformance ctx, cancel := context.WithCancel(context.Background()) sigChan := make(chan os.Signal, 1) @@ -577,7 +577,7 @@ func noServicesToOperateResult(stateDesc, opVerb string) BulkServiceControlResul // handleNoServicesCase handles the common pattern when --all finds no applicable services. // Returns true if the case was handled (caller should return), false otherwise. -func handleNoServicesCase(ctrl *ServiceController, stateDesc, opVerb string) bool { +func handleNoServicesCase(ctrl *ServiceController, stateDesc, opVerb string) bool { //nolint:unparam // return value kept for future use/interface conformance if len(ctrl.GetAllServices()) == 0 { printNoServicesRegistered() if cliout.IsJSON() { @@ -605,7 +605,7 @@ func oppositeState(state string) string { } // confirmBulkOperation prompts for confirmation of bulk operations. -// Returns true if the user confirms or skipConfirm is true, false if cancelled. +// Returns true if the user confirms or skipConfirm is true, false if canceled. func confirmBulkOperation(count int, opVerb string, skipConfirm bool) bool { if skipConfirm || cliout.IsJSON() { return true diff --git a/cli/src/cmd/app/commands/stop.go b/cli/src/cmd/app/commands/stop.go index e8d382eac..40a6f0697 100644 --- a/cli/src/cmd/app/commands/stop.go +++ b/cli/src/cmd/app/commands/stop.go @@ -78,7 +78,7 @@ func runStop(cmd *cobra.Command, args []string) error { } } if !confirmBulkOperation(len(servicesToStop), "stop", stopYes) { - cliout.Info("Operation cancelled") + cliout.Info("Operation canceled") return nil } } else { diff --git a/cli/src/internal/azure/bicep.go b/cli/src/internal/azure/bicep.go index 63dfbdebb..f503be2ab 100644 --- a/cli/src/internal/azure/bicep.go +++ b/cli/src/internal/azure/bicep.go @@ -270,7 +270,7 @@ resource functionAppDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-0 } // buildInstructions creates integration instructions for the Bicep template. -func (g *BicepGenerator) buildInstructions(serviceTypes map[ResourceType]bool) BicepInstructions { +func (g *BicepGenerator) buildInstructions(_ map[ResourceType]bool) BicepInstructions { steps := []string{ "Save this template as infra/modules/diagnostic-settings.bicep in your project", "Ensure your main.bicep has a Log Analytics workspace resource or parameter", diff --git a/cli/src/internal/azure/client_pool_test.go b/cli/src/internal/azure/client_pool_test.go index d59cee841..b57451d61 100644 --- a/cli/src/internal/azure/client_pool_test.go +++ b/cli/src/internal/azure/client_pool_test.go @@ -227,7 +227,7 @@ func TestGetOrCreateLogAnalyticsClient_ThreadSafety(t *testing.T) { } // Collect all clients - var clients []*LogAnalyticsClient + clients := make([]*LogAnalyticsClient, 0, numGoroutines) for client := range results { clients = append(clients, client) } @@ -465,7 +465,7 @@ func TestClientPool_DoubleCheckedLocking(t *testing.T) { close(results) // Collect results - var clients []*LogAnalyticsClient + clients := make([]*LogAnalyticsClient, 0, 2) for client := range results { clients = append(clients, client) } diff --git a/cli/src/internal/azure/diagnostic_engine.go b/cli/src/internal/azure/diagnostic_engine.go index 47913eb49..208b4a62b 100644 --- a/cli/src/internal/azure/diagnostic_engine.go +++ b/cli/src/internal/azure/diagnostic_engine.go @@ -11,6 +11,7 @@ import ( // DiagnosticStatus represents the overall health status of a service's logging configuration. type DiagnosticStatus string +// DiagnosticStatusHealthy and related constants define logging diagnostic outcomes for a service. const ( DiagnosticStatusHealthy DiagnosticStatus = "healthy" // Logs flowing correctly DiagnosticStatusPartial DiagnosticStatus = "partial" // Configured but no logs @@ -21,6 +22,7 @@ const ( // RequirementStatus represents whether a specific requirement is met. type RequirementStatus string +// RequirementStatusMet and related constants define whether a diagnostic requirement is satisfied. const ( RequirementStatusMet RequirementStatus = "met" RequirementStatusNotMet RequirementStatus = "not-met" diff --git a/cli/src/internal/azure/diagnostics.go b/cli/src/internal/azure/diagnostics.go index 8af693177..42866608a 100644 --- a/cli/src/internal/azure/diagnostics.go +++ b/cli/src/internal/azure/diagnostics.go @@ -20,6 +20,7 @@ import ( // DiagnosticSettingsStatus represents the status of diagnostic settings for a service. type DiagnosticSettingsStatus string +// DiagnosticSettingsConfigured and related constants describe whether diagnostic settings are available for a service. const ( DiagnosticSettingsConfigured DiagnosticSettingsStatus = "configured" DiagnosticSettingsNotConfigured DiagnosticSettingsStatus = "not-configured" diff --git a/cli/src/internal/azure/discovery.go b/cli/src/internal/azure/discovery.go index d4694a0f4..9049d5795 100644 --- a/cli/src/internal/azure/discovery.go +++ b/cli/src/internal/azure/discovery.go @@ -20,6 +20,7 @@ import ( // ResourceType represents the type of Azure compute resource. type ResourceType string +// ResourceTypeContainerApp and related constants identify supported Azure compute resource types. const ( ResourceTypeContainerApp ResourceType = "containerApp" ResourceTypeAppService ResourceType = "appService" @@ -170,7 +171,7 @@ func (d *ResourceDiscovery) Discover(ctx context.Context) (*DiscoveryResult, err } // getAzdEnvValues runs 'azd env get-values' and parses the output. -func (d *ResourceDiscovery) getAzdEnvValues(ctx context.Context) (map[string]string, error) { +func (d *ResourceDiscovery) getAzdEnvValues(ctx context.Context) (map[string]string, error) { //nolint:unparam // return value kept for future use/interface conformance cmd := exec.CommandContext(ctx, "azd", "env", "get-values", "--output", "json") if d.projectDir != "" { cmd.Dir = d.projectDir diff --git a/cli/src/internal/azure/loganalytics.go b/cli/src/internal/azure/loganalytics.go index abcd7b043..195a8c7df 100644 --- a/cli/src/internal/azure/loganalytics.go +++ b/cli/src/internal/azure/loganalytics.go @@ -15,6 +15,7 @@ import ( // LogLevel represents the severity of a log message. type LogLevel int +// LogLevelInfo and related constants define the severity levels assigned to Log Analytics entries. const ( LogLevelInfo LogLevel = iota LogLevelWarn @@ -315,7 +316,7 @@ func (c *LogAnalyticsClient) extractMessage(row []any, colIndex map[string]int, } // extractLevel extracts the log level from the row. -func (c *LogAnalyticsClient) extractLevel(row []any, colIndex map[string]int, message string, resourceType ResourceType) LogLevel { +func (c *LogAnalyticsClient) extractLevel(row []any, colIndex map[string]int, message string, _ ResourceType) LogLevel { // Try to get explicit level field levelStr := getStringFromRow(row, colIndex, "Level", "Stream_s") levelStr = strings.ToLower(levelStr) diff --git a/cli/src/internal/azure/loganalytics_integration_test.go b/cli/src/internal/azure/loganalytics_integration_test.go index f831a6f4c..0bd82f85b 100644 --- a/cli/src/internal/azure/loganalytics_integration_test.go +++ b/cli/src/internal/azure/loganalytics_integration_test.go @@ -90,7 +90,7 @@ func TestLogAnalyticsQuery_Integration(t *testing.T) { } func truncateRow(row []any) []string { - var result []string + result := make([]string, 0, len(row)) for _, v := range row { s := fmt.Sprintf("%v", v) if len(s) > 50 { diff --git a/cli/src/internal/azure/parse_results_test.go b/cli/src/internal/azure/parse_results_test.go index d081db3bb..d3785fe09 100644 --- a/cli/src/internal/azure/parse_results_test.go +++ b/cli/src/internal/azure/parse_results_test.go @@ -268,7 +268,7 @@ func createMockLogsResponse(rows []mockRow) azlogs.QueryWorkspaceResponse { } // Convert rows to interface slices - var rowData []azlogs.Row + rowData := make([]azlogs.Row, 0, len(rows)) for _, r := range rows { row := azlogs.Row{ r.TimeGenerated, @@ -312,7 +312,7 @@ func createMockLogsResponseWithoutSource(rows []mockRowNoSource) azlogs.QueryWor {Name: &levelCol, Type: &typeStr}, } - var rowData []azlogs.Row + rowData := make([]azlogs.Row, 0, len(rows)) for _, r := range rows { row := azlogs.Row{ r.TimeGenerated, @@ -352,7 +352,7 @@ func createMockLogsResponseWithNullSource(rows []mockRowNullSource) azlogs.Query {Name: &levelCol, Type: &typeStr}, } - var rowData []azlogs.Row + rowData := make([]azlogs.Row, 0, len(rows)) for _, r := range rows { row := azlogs.Row{ r.TimeGenerated, diff --git a/cli/src/internal/azure/query_builder.go b/cli/src/internal/azure/query_builder.go index 848d6e929..ac660ac8d 100644 --- a/cli/src/internal/azure/query_builder.go +++ b/cli/src/internal/azure/query_builder.go @@ -15,6 +15,8 @@ const ( // defaultQueryResultLimit is the default limit for query results to prevent excessive data transfer. // Set to 1000 rows as a balance between completeness and performance. defaultQueryResultLimit = 1000 + testTimespan = "30m" + fieldMessage = "Message" ) // QueryBuilder helps construct KQL queries from table selections. @@ -34,7 +36,7 @@ func NewQueryBuilder(serviceName string, timespan string) *QueryBuilder { sanitizedName := sanitizeKQLString(serviceName) // Validate timespan format to prevent injection if !kqlTimespanPattern.MatchString(timespan) { - timespan = "30m" // Safe default + timespan = testTimespan // Safe default } return &QueryBuilder{ serviceName: sanitizedName, @@ -96,7 +98,7 @@ func (qb *QueryBuilder) buildSingleTableQuery(tableName string) string { // buildUnionQuery generates a union query for multiple tables. func (qb *QueryBuilder) buildUnionQuery() string { - var parts []string + parts := make([]string, 0, len(qb.tables)) for _, tableName := range qb.tables { filter := qb.getServiceFilter(tableName) @@ -164,11 +166,11 @@ func (qb *QueryBuilder) getProjectColumns(tableName string) string { columns := TableColumns[tableName] if len(columns) == 0 { // Default columns if table is not known - return "Message=tostring(column_ifexists('Message', '')), Level=tostring(column_ifexists('Level', 'INFO'))" + return fieldMessage + "=tostring(column_ifexists('" + fieldMessage + "', '')), Level=tostring(column_ifexists('Level', 'INFO'))" } // Build projection with column aliases for common fields - var parts []string + parts := make([]string, 0, len(columns)) for _, col := range columns { if col == "TimeGenerated" { continue // Already in the base projection @@ -178,8 +180,8 @@ func (qb *QueryBuilder) getProjectColumns(tableName string) string { // Add Message alias for display messageCol := getMessageColumn(tableName) - if messageCol != "" && !containsString(parts, "Message") { - parts = append(parts, fmt.Sprintf("Message=%s", messageCol)) + if messageCol != "" && !containsString(parts, fieldMessage) { + parts = append(parts, fmt.Sprintf("%s=%s", fieldMessage, messageCol)) } return strings.Join(parts, ", ") @@ -197,9 +199,9 @@ func getMessageColumn(tableName string) string { case "AppServiceHTTPLogs": return "CsUriStem" case "AppServicePlatformLogs": - return "Message" + return fieldMessage case "FunctionAppLogs": - return "Message" + return fieldMessage case "ContainerLogV2": return "LogMessage" case "ContainerLog": @@ -207,7 +209,7 @@ func getMessageColumn(tableName string) string { case "ContainerInstanceLog_CL": return "Message_s" case "KubeEvents": - return "Message" + return fieldMessage default: return "" } @@ -237,7 +239,7 @@ func SubstitutePlaceholders(query, serviceName, timespan string) string { query = strings.ReplaceAll(query, "{serviceName}", sanitizeKQLString(serviceName)) // Validate timespan format to prevent injection if !kqlTimespanPattern.MatchString(timespan) { - timespan = "30m" // Safe default + timespan = testTimespan // Safe default } query = strings.ReplaceAll(query, "{timespan}", timespan) return query diff --git a/cli/src/internal/azure/query_builder_test.go b/cli/src/internal/azure/query_builder_test.go index b75d6bb70..3ba160094 100644 --- a/cli/src/internal/azure/query_builder_test.go +++ b/cli/src/internal/azure/query_builder_test.go @@ -7,7 +7,6 @@ import ( const ( testServiceName = "test-service" - testTimespan = "30m" testMyApp = "my-app" nonEmptyQueryMessage = "Build should return non-empty query" orderByTimeDescending = "order by TimeGenerated desc" @@ -22,8 +21,8 @@ func TestNewQueryBuilder(t *testing.T) { if qb.serviceName != "test-service" { t.Errorf("serviceName = %q, want %q", qb.serviceName, "test-service") } - if qb.timespan != "30m" { - t.Errorf("timespan = %q, want %q", qb.timespan, "30m") + if qb.timespan != testTimespan { + t.Errorf("timespan = %q, want %q", qb.timespan, testTimespan) } if len(qb.tables) != 0 { t.Errorf("tables should be empty, got %d tables", len(qb.tables)) @@ -31,7 +30,7 @@ func TestNewQueryBuilder(t *testing.T) { } func TestQueryBuilder_WithTables(t *testing.T) { - qb := NewQueryBuilder("test-service", "30m") + qb := NewQueryBuilder("test-service", testTimespan) tables := []string{"ContainerAppConsoleLogs_CL", "AppServiceConsoleLogs"} result := qb.WithTables(tables) @@ -48,7 +47,7 @@ func TestQueryBuilder_WithTables(t *testing.T) { } func TestQueryBuilder_Build_EmptyTables(t *testing.T) { - qb := NewQueryBuilder("test-service", "30m") + qb := NewQueryBuilder("test-service", testTimespan) query := qb.Build() @@ -58,7 +57,7 @@ func TestQueryBuilder_Build_EmptyTables(t *testing.T) { } func TestQueryBuilder_Build_SingleTable(t *testing.T) { - qb := NewQueryBuilder("test-service", "30m"). + qb := NewQueryBuilder("test-service", testTimespan). WithTables([]string{"ContainerAppConsoleLogs_CL"}) query := qb.Build() @@ -105,7 +104,7 @@ func TestQueryBuilder_Build_SingleTable_NoServiceFilter(t *testing.T) { } func TestQueryBuilder_Build_MultipleTablesUnion(t *testing.T) { - qb := NewQueryBuilder("test-service", "30m"). + qb := NewQueryBuilder("test-service", testTimespan). WithTables([]string{"ContainerAppConsoleLogs_CL", "ContainerAppSystemLogs_CL"}) query := qb.Build() @@ -188,7 +187,7 @@ func TestGetMessageColumn(t *testing.T) { } func TestGetProjectColumns(t *testing.T) { - qb := NewQueryBuilder("test-service", "30m") + qb := NewQueryBuilder("test-service", testTimespan) // Test with known table columns := qb.getProjectColumns("ContainerAppConsoleLogs_CL") @@ -247,7 +246,7 @@ func TestBuildQueryFromTables(t *testing.T) { func TestBuildQueryFromTables_MultipleServices(t *testing.T) { tables := []string{"AppServiceConsoleLogs", "AppServiceHTTPLogs"} - query := BuildQueryFromTables(tables, "my-app", "30m") + query := BuildQueryFromTables(tables, "my-app", testTimespan) if query == "" { t.Fatal("BuildQueryFromTables should return non-empty query") @@ -276,7 +275,7 @@ func TestSubstitutePlaceholders(t *testing.T) { name: "Replace service name", query: "MyTable | where Service == '{serviceName}'", serviceName: "test-service", - timespan: "30m", + timespan: testTimespan, wantContain: []string{"test-service"}, }, { @@ -297,7 +296,7 @@ func TestSubstitutePlaceholders(t *testing.T) { name: "No placeholders", query: "MyTable | project TimeGenerated, Message", serviceName: "test-service", - timespan: "30m", + timespan: testTimespan, wantContain: []string{"MyTable", "TimeGenerated", "Message"}, }, } @@ -327,7 +326,7 @@ func TestSubstitutePlaceholders_SanitizeServiceName(t *testing.T) { // Service names should be sanitized to prevent KQL injection query := "MyTable | where Service == '{serviceName}'" serviceName := "test'; drop table--" - timespan := "30m" + timespan := testTimespan result := SubstitutePlaceholders(query, serviceName, timespan) @@ -390,7 +389,7 @@ func TestQueryBuilder_Build_DifferentResourceTypes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - qb := NewQueryBuilder(tt.serviceName, "30m"). + qb := NewQueryBuilder(tt.serviceName, testTimespan). WithTables([]string{tt.table}) query := qb.Build() @@ -403,7 +402,7 @@ func TestQueryBuilder_Build_DifferentResourceTypes(t *testing.T) { } func TestQueryBuilder_Build_ResultLimit(t *testing.T) { - qb := NewQueryBuilder("test-service", "30m"). + qb := NewQueryBuilder("test-service", testTimespan). WithTables([]string{"ContainerAppConsoleLogs_CL"}) query := qb.Build() @@ -415,7 +414,7 @@ func TestQueryBuilder_Build_ResultLimit(t *testing.T) { } func TestQueryBuilder_Build_TimeOrdering(t *testing.T) { - qb := NewQueryBuilder("test-service", "30m"). + qb := NewQueryBuilder("test-service", testTimespan). WithTables([]string{"ContainerAppConsoleLogs_CL"}) query := qb.Build() diff --git a/cli/src/internal/azure/realtime.go b/cli/src/internal/azure/realtime.go index 8a37ea313..03b58af5c 100644 --- a/cli/src/internal/azure/realtime.go +++ b/cli/src/internal/azure/realtime.go @@ -34,8 +34,8 @@ const ( // Implementations provide low-latency log streaming using service-specific APIs. type RealtimeLogStreamer interface { // Start begins streaming logs. Logs are sent to the provided channel. - // The method blocks until the context is cancelled or an error occurs. - // Returns nil on graceful shutdown (context cancelled), error otherwise. + // The method blocks until the context is canceled or an error occurs. + // Returns nil on graceful shutdown (context canceled), error otherwise. Start(ctx context.Context, logs chan<- LogEntry) error // Stop gracefully stops the streamer. @@ -105,16 +105,19 @@ func (b *baseStreamer) setConnected(connected bool) { b.connected = connected } +// IsConnected reports whether the streamer currently has an active connection. func (b *baseStreamer) IsConnected() bool { b.connectedMu.RLock() defer b.connectedMu.RUnlock() return b.connected } +// ServiceName returns the configured service name for the streamer. func (b *baseStreamer) ServiceName() string { return b.config.ServiceName } +// Stop closes the streamer stop channel and marks the streamer as disconnected. func (b *baseStreamer) Stop() error { b.stopOnce.Do(func() { close(b.stopCh) diff --git a/cli/src/internal/azure/realtime_test.go b/cli/src/internal/azure/realtime_test.go index 9acd45746..5b40baeb9 100644 --- a/cli/src/internal/azure/realtime_test.go +++ b/cli/src/internal/azure/realtime_test.go @@ -367,7 +367,7 @@ data: {"Log":"Error occurred","Stream":"stderr","time":"2024-01-15T10:30:47.123Z }() // Collect results - var entries []LogEntry + entries := make([]LogEntry, 0, 3) for entry := range logs { entries = append(entries, entry) } diff --git a/cli/src/internal/azure/standalone_logs.go b/cli/src/internal/azure/standalone_logs.go index c20677448..30025e8f1 100644 --- a/cli/src/internal/azure/standalone_logs.go +++ b/cli/src/internal/azure/standalone_logs.go @@ -24,6 +24,7 @@ const ( kqlWhere = "| where %s\n" kqlResourceIDFilter = "_ResourceId contains '%s'" azureDirName = ".azure" + valTrue = "true" ) // StandaloneLogsConfig holds configuration for standalone Azure log fetching. @@ -73,11 +74,11 @@ func getServicesFromAzureYAML(projectDir string) ([]ServiceInfo, error) { serviceNameMap := getServiceNameMap(projectDir) // Debug: log service name mapping - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Service name map from environment: %v\n", serviceNameMap) } - var services []ServiceInfo + services := make([]ServiceInfo, 0, len(config.Services)) for name, svc := range config.Services { // Skip local-only services if svc.Host == "local" || svc.Host == "" { @@ -104,7 +105,7 @@ func getServicesFromAzureYAML(projectDir string) ([]ServiceInfo, error) { } // Debug: log each service mapping - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Service %s: host=%s, resourceType=%s, azureName=%s\n", name, svc.Host, info.ResourceType, info.AzureName) } @@ -121,7 +122,7 @@ var serviceNameEnvRe = regexp.MustCompile(`^SERVICE_(.+)_NAME$`) // getServiceNameMap returns a map of azure.yaml service names to Azure resource names. // Uses environment variables directly since the azd extension framework provides them. -func getServiceNameMap(projectDir string) map[string]string { +func getServiceNameMap(_ string) map[string]string { serviceNameMap := make(map[string]string) // When running as an azd extension, all environment variables are already available @@ -172,10 +173,10 @@ func FetchAzureLogsStandalone(ctx context.Context, config StandaloneLogsConfig) // Try auto-discovery if discovered, wasDiscovered, err := DiscoverAndStoreWorkspaceID(ctx); err == nil && wasDiscovered { workspaceID = discovered - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Auto-discovered workspace ID: %s\n", workspaceID) } - } else if err != nil && os.Getenv("AZD_APP_DEBUG") == "true" { + } else if err != nil && os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Workspace discovery failed: %v\n", err) } } @@ -258,7 +259,7 @@ func FetchAzureLogsStandalone(ctx context.Context, config StandaloneLogsConfig) } // Debug: log service grouping - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Target services: %v\n", targetServices) fmt.Fprintf(os.Stderr, "[DEBUG] Services by type: %v\n", servicesByType) } @@ -284,7 +285,7 @@ func FetchAzureLogsStandalone(ctx context.Context, config StandaloneLogsConfig) if ctx.Err() == nil { // Log error but continue with other resource types slog.Warn("Query failed for resource type", "resourceType", resourceType, "error", err) - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Query failed for %s: %v\n", resourceType, err) } } @@ -496,7 +497,7 @@ func DiscoverAndStoreWorkspaceID(ctx context.Context) (string, bool, error) { } // Try to discover workspace using az CLI - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Attempting to discover workspace in resource group: %s\n", resourceGroup) } @@ -519,7 +520,7 @@ func DiscoverAndStoreWorkspaceID(ctx context.Context) (string, bool, error) { return "", false, fmt.Errorf("failed to store workspace ID: %w", err) } - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Discovered and stored workspace ID: %s\n", workspaceID) } @@ -759,7 +760,7 @@ type StreamConfig struct { } // StreamAzureLogsStandalone streams Azure logs by polling Log Analytics. -// Logs are sent to the provided channel. The function blocks until ctx is cancelled. +// Logs are sent to the provided channel. The function blocks until ctx is canceled. // This enables `azd app logs -f --source azure` without requiring `azd app run`. func StreamAzureLogsStandalone(ctx context.Context, config StreamConfig, logs chan<- LogEntry) error { // Get workspace ID from environment if not provided @@ -773,10 +774,10 @@ func StreamAzureLogsStandalone(ctx context.Context, config StreamConfig, logs ch // Try auto-discovery if discovered, wasDiscovered, err := DiscoverAndStoreWorkspaceID(ctx); err == nil && wasDiscovered { workspaceID = discovered - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Auto-discovered workspace ID: %s\n", workspaceID) } - } else if err != nil && os.Getenv("AZD_APP_DEBUG") == "true" { + } else if err != nil && os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Workspace discovery failed: %v\n", err) } } @@ -856,7 +857,7 @@ func StreamAzureLogsStandalone(ctx context.Context, config StreamConfig, logs ch if window <= 0 { window = 1 * time.Hour } - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Streaming initial window: %v\n", window) } lastSeen := time.Now().Add(-window) @@ -867,7 +868,7 @@ func StreamAzureLogsStandalone(ctx context.Context, config StreamConfig, logs ch // Do initial fetch immediately if err := fetchAndSendLogsMultiType(ctx, client, servicesByType, lastSeen, logs, &lastSeen); err != nil { // Log error but continue - transient failures shouldn't stop streaming - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Initial fetch failed: %v\n", err) } // Don't return - continue to poll loop @@ -882,7 +883,7 @@ func StreamAzureLogsStandalone(ctx context.Context, config StreamConfig, logs ch if err := fetchAndSendLogsMultiType(ctx, client, servicesByType, lastSeen, logs, &lastSeen); err != nil { // For streaming, we don't return on transient errors // Just skip this poll cycle - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Poll fetch failed: %v\n", err) } continue @@ -892,7 +893,7 @@ func StreamAzureLogsStandalone(ctx context.Context, config StreamConfig, logs ch } // fetchAndSendLogsMultiType fetches logs from multiple resource types and sends them to the channel. -func fetchAndSendLogsMultiType(ctx context.Context, client *LogAnalyticsClient, servicesByType map[ResourceType][]ServiceInfo, since time.Time, logs chan<- LogEntry, lastSeen *time.Time) error { +func fetchAndSendLogsMultiType(ctx context.Context, client *LogAnalyticsClient, servicesByType map[ResourceType][]ServiceInfo, _ time.Time, logs chan<- LogEntry, lastSeen *time.Time) error { // Use precise timestamp filtering instead of ago() to avoid duplicate fetches // This queries: TimeGenerated > lastSeen instead of TimeGenerated > ago(Nm) @@ -909,14 +910,14 @@ func fetchAndSendLogsMultiType(ctx context.Context, client *LogAnalyticsClient, // Build query with timestamp-based filtering query := buildTimestampQuery(resourceType, azureNames, *lastSeen) - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Streaming query for %s: %s\n", resourceType, strings.ReplaceAll(query, "\n", " | ")) } // Query using custom query (bypasses ago() duration logic) entries, err := client.QueryLogs(ctx, "", resourceType, 0, query) if err != nil { - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Query failed for %s: %v\n", resourceType, err) } errorCount++ @@ -936,7 +937,7 @@ func fetchAndSendLogsMultiType(ctx context.Context, client *LogAnalyticsClient, // Sort by timestamp ascending for streaming (oldest first) sortLogEntriesByTimeAsc(allEntries) - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Fetched %d total entries, lastSeen=%v\n", len(allEntries), lastSeen.Format(time.RFC3339)) } @@ -955,12 +956,12 @@ func fetchAndSendLogsMultiType(ctx context.Context, client *LogAnalyticsClient, } } - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] Sent %d entries to channel\n", sentCount) } // Fix 3: Last poll timestamp only shown in debug mode (was always shown, spamming stderr) - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { fmt.Fprintf(os.Stderr, "[DEBUG] [%s] Last polled (sent %d entries)\n", time.Now().Format("15:04:05"), sentCount) } diff --git a/cli/src/internal/azure/token_cache.go b/cli/src/internal/azure/token_cache.go index d2858df46..c9b66a247 100644 --- a/cli/src/internal/azure/token_cache.go +++ b/cli/src/internal/azure/token_cache.go @@ -34,7 +34,7 @@ func (tc *TokenCache) Get() string { // Check if token exists and hasn't expired if tc.token == "" || time.Now().After(tc.expiresAt) { - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { if tc.token == "" { slog.Debug("Token cache miss", "reason", "no token", "scope", tc.scope) } else { @@ -44,7 +44,7 @@ func (tc *TokenCache) Get() string { return "" } - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { slog.Debug("Token cache hit", "expiresIn", time.Until(tc.expiresAt).Round(time.Second), "scope", tc.scope) } @@ -62,7 +62,7 @@ func (tc *TokenCache) Set(token string) { // Azure tokens are typically valid for 1 hour, so 5 minutes is a safe refresh interval tc.expiresAt = time.Now().Add(5 * time.Minute) - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { slog.Debug("Token cached", "expiresAt", tc.expiresAt, "scope", tc.scope) } } @@ -74,7 +74,7 @@ func (tc *TokenCache) Clear() { tc.mu.Lock() defer tc.mu.Unlock() - if os.Getenv("AZD_APP_DEBUG") == "true" { + if os.Getenv("AZD_APP_DEBUG") == valTrue { if tc.token != "" { slog.Debug("Token cache cleared", "reason", "auth error", "scope", tc.scope) } diff --git a/cli/src/internal/azure/validator_appservice.go b/cli/src/internal/azure/validator_appservice.go index c4420a97b..0e65e160c 100644 --- a/cli/src/internal/azure/validator_appservice.go +++ b/cli/src/internal/azure/validator_appservice.go @@ -31,7 +31,7 @@ func NewAppServiceValidator(credential azcore.TokenCredential, projectDir string } // Validate performs comprehensive validation of an App Service's logging configuration. -func (v *AppServiceValidator) Validate(ctx context.Context, serviceName string, resource *AzureResource) (*ServiceDiagnosticResult, error) { +func (v *AppServiceValidator) Validate(ctx context.Context, serviceName string, resource *AzureResource) (*ServiceDiagnosticResult, error) { //nolint:dupl // structurally similar validator but different Azure resource type result := &ServiceDiagnosticResult{ HostType: ResourceTypeAppService, Requirements: make([]Requirement, 0), @@ -114,7 +114,7 @@ func (v *AppServiceValidator) Validate(ctx context.Context, serviceName string, } // checkDiagnosticSettings checks if diagnostic settings are configured for the App Service. -func (v *AppServiceValidator) checkDiagnosticSettings(ctx context.Context, resourceID string) (bool, error) { +func (v *AppServiceValidator) checkDiagnosticSettings(ctx context.Context, resourceID string) (bool, error) { //nolint:unparam // return value kept for future use/interface conformance checker := &DiagnosticSettingsChecker{ credential: v.credential, projectDir: v.projectDir, @@ -125,7 +125,7 @@ func (v *AppServiceValidator) checkDiagnosticSettings(ctx context.Context, resou } // generateSetupGuide creates a setup guide for App Service. -func (v *AppServiceValidator) generateSetupGuide(serviceName string, resource *AzureResource, hasSettings bool, hasLogs bool) *SetupGuide { +func (v *AppServiceValidator) generateSetupGuide(_ string, _ *AzureResource, hasSettings bool, hasLogs bool) *SetupGuide { if hasLogs { return nil } diff --git a/cli/src/internal/azure/validator_containerapp.go b/cli/src/internal/azure/validator_containerapp.go index d07e8d21c..c8d8eb9f2 100644 --- a/cli/src/internal/azure/validator_containerapp.go +++ b/cli/src/internal/azure/validator_containerapp.go @@ -31,7 +31,7 @@ func NewContainerAppValidator(credential azcore.TokenCredential, projectDir stri } // Validate performs comprehensive validation of a Container App's logging configuration. -func (v *ContainerAppValidator) Validate(ctx context.Context, serviceName string, resource *AzureResource) (*ServiceDiagnosticResult, error) { +func (v *ContainerAppValidator) Validate(ctx context.Context, serviceName string, resource *AzureResource) (*ServiceDiagnosticResult, error) { //nolint:dupl // structurally similar validator but different Azure resource type result := &ServiceDiagnosticResult{ HostType: ResourceTypeContainerApp, Requirements: make([]Requirement, 0), @@ -114,7 +114,7 @@ func (v *ContainerAppValidator) Validate(ctx context.Context, serviceName string } // checkDiagnosticSettings checks if diagnostic settings are configured for the Container App. -func (v *ContainerAppValidator) checkDiagnosticSettings(ctx context.Context, resourceID string) (bool, error) { +func (v *ContainerAppValidator) checkDiagnosticSettings(ctx context.Context, resourceID string) (bool, error) { //nolint:unparam // return value kept for future use/interface conformance checker := &DiagnosticSettingsChecker{ credential: v.credential, projectDir: v.projectDir, @@ -125,7 +125,7 @@ func (v *ContainerAppValidator) checkDiagnosticSettings(ctx context.Context, res } // generateSetupGuide creates a setup guide for Container Apps. -func (v *ContainerAppValidator) generateSetupGuide(serviceName string, resource *AzureResource, hasSettings bool, hasLogs bool) *SetupGuide { +func (v *ContainerAppValidator) generateSetupGuide(_ string, _ *AzureResource, hasSettings bool, hasLogs bool) *SetupGuide { if hasLogs { return nil // No guide needed if logs are flowing } diff --git a/cli/src/internal/azure/validator_function.go b/cli/src/internal/azure/validator_function.go index 6d1681f8b..0b3f0e6b3 100644 --- a/cli/src/internal/azure/validator_function.go +++ b/cli/src/internal/azure/validator_function.go @@ -132,7 +132,7 @@ func (v *FunctionValidator) Validate(ctx context.Context, serviceName string, re } // checkApplicationInsights checks if Application Insights is configured in the environment. -func (v *FunctionValidator) checkApplicationInsights(ctx context.Context, serviceName string) (bool, error) { +func (v *FunctionValidator) checkApplicationInsights(ctx context.Context, _ string) (bool, error) { // Run azd env get-values to check for APPLICATIONINSIGHTS_CONNECTION_STRING cmd := exec.CommandContext(ctx, "azd", "env", "get-values") if v.projectDir != "" { @@ -149,7 +149,7 @@ func (v *FunctionValidator) checkApplicationInsights(ctx context.Context, servic } // checkDiagnosticSettings checks if diagnostic settings are configured for the Function App. -func (v *FunctionValidator) checkDiagnosticSettings(ctx context.Context, resourceID string) (bool, error) { +func (v *FunctionValidator) checkDiagnosticSettings(ctx context.Context, resourceID string) (bool, error) { //nolint:unparam // return value kept for future use/interface conformance checker := &DiagnosticSettingsChecker{ credential: v.credential, projectDir: v.projectDir, @@ -160,7 +160,7 @@ func (v *FunctionValidator) checkDiagnosticSettings(ctx context.Context, resourc } // generateSetupGuide creates a setup guide for Azure Functions. -func (v *FunctionValidator) generateSetupGuide(serviceName string, resource *AzureResource, hasAppInsights bool, hasLogs bool) *SetupGuide { +func (v *FunctionValidator) generateSetupGuide(serviceName string, _ *AzureResource, hasAppInsights bool, hasLogs bool) *SetupGuide { if hasLogs { return nil } diff --git a/cli/src/internal/azure/verification.go b/cli/src/internal/azure/verification.go index 19bd46834..485e92b23 100644 --- a/cli/src/internal/azure/verification.go +++ b/cli/src/internal/azure/verification.go @@ -13,6 +13,7 @@ import ( // WorkspaceVerificationStatus represents the overall status of workspace verification. type WorkspaceVerificationStatus string +// VerificationStatusSuccess and related constants describe the overall outcome of workspace verification. const ( VerificationStatusSuccess WorkspaceVerificationStatus = "success" VerificationStatusPartial WorkspaceVerificationStatus = "partial" @@ -22,6 +23,7 @@ const ( // ServiceVerificationStatus represents the verification status of a single service. type ServiceVerificationStatus string +// ServiceStatusOK and related constants describe per-service verification results. const ( ServiceStatusOK ServiceVerificationStatus = "ok" ServiceStatusNoLogs ServiceVerificationStatus = "no-logs" diff --git a/cli/src/internal/config/notifications.go b/cli/src/internal/config/notifications.go index ddcb3ab7e..9910cb5a6 100644 --- a/cli/src/internal/config/notifications.go +++ b/cli/src/internal/config/notifications.go @@ -75,11 +75,17 @@ func ValidateServiceName(name string) error { // Thread-safe via sync.Map. var timeCache sync.Map +const severityWarning = "warning" + // parseTimeCached parses a time string with caching. // Returns the parsed time or an error if the format is invalid. func parseTimeCached(timeStr string) (time.Time, error) { if cached, ok := timeCache.Load(timeStr); ok { - return cached.(time.Time), nil + parsed, parsedOK := cached.(time.Time) + if !parsedOK { + return time.Time{}, fmt.Errorf("cached time for %q has unexpected type %T", timeStr, cached) + } + return parsed, nil } parsed, err := time.Parse("15:04", timeStr) @@ -208,10 +214,10 @@ func GetGlobalNotificationPreferences() *NotificationPreferences { func (p *NotificationPreferences) Validate() error { // Validate severity filter validSeverities := map[string]bool{ - "critical": true, - "warning": true, - "info": true, - "all": true, + "critical": true, + severityWarning: true, + "info": true, + "all": true, } if !validSeverities[p.SeverityFilter] { return fmt.Errorf("invalid severity filter: %s (must be critical, warning, info, or all)", p.SeverityFilter) @@ -335,10 +341,10 @@ func (p *NotificationPreferences) ShouldNotify(serviceName string, severity stri switch p.SeverityFilter { case "critical": return severity == "critical" - case "warning": - return severity == "critical" || severity == "warning" + case severityWarning: + return severity == "critical" || severity == severityWarning case "info": - return severity == "critical" || severity == "warning" || severity == "info" + return severity == "critical" || severity == severityWarning || severity == "info" case "all": return true default: diff --git a/cli/src/internal/dashboard/azure_logs_handlers.go b/cli/src/internal/dashboard/azure_logs_handlers.go index e64287d4d..53604ccda 100644 --- a/cli/src/internal/dashboard/azure_logs_handlers.go +++ b/cli/src/internal/dashboard/azure_logs_handlers.go @@ -150,7 +150,7 @@ func (s *Server) handleAzureLogs(w http.ResponseWriter, r *http.Request) { } if err != nil { - response.Status = "error" + response.Status = StatusError response.Count = 0 response.Error = mapAzureErrorToInfo(err) diff --git a/cli/src/internal/dashboard/azure_logs_health.go b/cli/src/internal/dashboard/azure_logs_health.go index 859141ab4..ca0044364 100644 --- a/cli/src/internal/dashboard/azure_logs_health.go +++ b/cli/src/internal/dashboard/azure_logs_health.go @@ -10,6 +10,12 @@ import ( "time" ) +const ( + statusPass = "pass" + statusFail = "fail" + statusWarn = "warn" +) + // HealthCheckResponse represents the overall health check result. type HealthCheckResponse struct { Status string `json:"status"` // "healthy" | "degraded" | "error" @@ -48,7 +54,7 @@ func (s *Server) handleAzureLogsHealth(w http.ResponseWriter, r *http.Request) { response.Checks = append(response.Checks, servicesCheck) // Check 4: Connectivity - connectivityCheck := s.checkConnectivity(workspaceCheck.Status == "pass") + connectivityCheck := s.checkConnectivity(workspaceCheck.Status == statusPass) response.Checks = append(response.Checks, connectivityCheck) // Compute overall status @@ -66,7 +72,7 @@ func (s *Server) checkAuthentication() HealthCheck { // Try to create credentials cred, err := newLogAnalyticsCredential() if err != nil { - check.Status = "fail" + check.Status = statusFail check.Message = "Azure credentials not available" check.Fix = "azd auth login" return check @@ -78,13 +84,13 @@ func (s *Server) checkAuthentication() HealthCheck { err = validateCredentials(ctx, cred) if err != nil { - check.Status = "fail" + check.Status = statusFail check.Message = "Azure credentials invalid or expired" check.Fix = "azd auth login" return check } - check.Status = "pass" + check.Status = statusPass check.Message = "Azure credentials valid" return check } @@ -97,19 +103,19 @@ func (s *Server) checkWorkspaceID() HealthCheck { workspaceID, err := getWorkspaceIDFromEnv(context.Background()) if err != nil { - check.Status = "fail" + check.Status = statusFail check.Message = "Log Analytics workspace not configured" check.Fix = "azd env refresh" return check } if workspaceID == "" { - check.Status = "warn" + check.Status = statusWarn check.Message = "No azd environment available; Azure log streaming unavailable" check.Fix = "Run 'azd init' and 'azd provision' to enable Azure log streaming" return check } - check.Status = "pass" + check.Status = statusPass check.Message = fmt.Sprintf("Workspace ID configured: %s", truncateMiddle(workspaceID, 20)) return check } @@ -131,13 +137,13 @@ func (s *Server) checkServicesDeployed() HealthCheck { } if serviceCount == 0 { - check.Status = "fail" + check.Status = statusFail check.Message = "No deployed services found" check.Fix = "azd up" return check } - check.Status = "pass" + check.Status = statusPass check.Message = fmt.Sprintf("Found %d deployed service(s)", serviceCount) return check } @@ -149,21 +155,21 @@ func (s *Server) checkConnectivity(hasWorkspace bool) HealthCheck { } if !hasWorkspace { - check.Status = "warn" + check.Status = statusWarn check.Message = "Cannot verify connectivity without workspace ID" return check } workspaceID, err := getWorkspaceIDFromEnv(context.Background()) if err != nil || workspaceID == "" { - check.Status = "warn" + check.Status = statusWarn check.Message = "Cannot get workspace ID" return check } cred, err := newLogAnalyticsCredential() if err != nil { - check.Status = "warn" + check.Status = statusWarn check.Message = "Cannot create credentials for connectivity test" return check } @@ -173,13 +179,13 @@ func (s *Server) checkConnectivity(hasWorkspace bool) HealthCheck { _, err = getOrCreateLogAnalyticsClient(ctx, cred, workspaceID) if err != nil { slog.Error("failed to create Log Analytics client", "error", err) - check.Status = "fail" + check.Status = statusFail check.Message = "Failed to connect to Log Analytics" check.Fix = "Check Azure subscription and permissions" return check } - check.Status = "pass" + check.Status = statusPass check.Message = "Log Analytics client created successfully" return check } @@ -191,15 +197,15 @@ func (s *Server) computeOverallStatus(checks []HealthCheck) string { for _, check := range checks { switch check.Status { - case "fail": + case statusFail: hasError = true - case "warn": + case statusWarn: hasWarn = true } } if hasError { - return "error" + return StatusError } if hasWarn { return "degraded" diff --git a/cli/src/internal/dashboard/azure_logs_query.go b/cli/src/internal/dashboard/azure_logs_query.go index fc918cfed..7ff48e560 100644 --- a/cli/src/internal/dashboard/azure_logs_query.go +++ b/cli/src/internal/dashboard/azure_logs_query.go @@ -9,6 +9,11 @@ import ( "github.com/jongio/azd-app/cli/src/internal/service" ) +const ( + queryTypeCustom = "custom" + defaultQueryTimespan = "30m" +) + // AzureQueryResponse represents the KQL query used for a service. type AzureQueryResponse struct { Service string `json:"service"` @@ -51,7 +56,7 @@ func (s *Server) handleAzureQuery(w http.ResponseWriter, r *http.Request) { if svc, ok := azureYaml.Services[serviceName]; ok && svc.Logs != nil && svc.Logs.Analytics != nil { if svc.Logs.Analytics.Query != "" { query = svc.Logs.Analytics.Query - resourceType = "custom" + resourceType = queryTypeCustom } } } @@ -62,7 +67,7 @@ func (s *Server) handleAzureQuery(w http.ResponseWriter, r *http.Request) { resourceType = "containerapp" // Default assumption query = azure.GetDefaultQuery(azure.ResourceType(resourceType)) // Substitute placeholders for display - query = substituteQueryPlaceholders(query, serviceName, "30m") + query = substituteQueryPlaceholders(query, serviceName, defaultQueryTimespan) } response := AzureQueryResponse{ @@ -137,7 +142,7 @@ func (s *Server) handleSaveAzureQuery(w http.ResponseWriter, r *http.Request) { // Return updated query info response := AzureQueryResponse{ Service: req.Service, - ResourceType: "custom", + ResourceType: queryTypeCustom, Query: req.Query, } diff --git a/cli/src/internal/dashboard/azure_logs_tables.go b/cli/src/internal/dashboard/azure_logs_tables.go index 68ba284dc..6814b7bce 100644 --- a/cli/src/internal/dashboard/azure_logs_tables.go +++ b/cli/src/internal/dashboard/azure_logs_tables.go @@ -10,6 +10,8 @@ import ( "github.com/jongio/azd-core/yamlutil" ) +const resourceTables = "tables" + // TablesResponse represents the response from the tables API. type TablesResponse struct { Tables []azure.TableInfo `json:"tables"` @@ -127,7 +129,7 @@ func (s *Server) handleGetLogConfig(w http.ResponseWriter, r *http.Request) { response := LogConfigResponse{ Service: serviceName, - Mode: "tables", + Mode: resourceTables, ResourceType: "containerapp", // Default } @@ -150,16 +152,16 @@ func (s *Server) handleGetLogConfig(w http.ResponseWriter, r *http.Request) { svcConfig := svc.Logs.Analytics if len(svcConfig.Tables) > 0 { response.Tables = svcConfig.Tables - response.Mode = "tables" + response.Mode = resourceTables } if svcConfig.Query != "" { response.Query = svcConfig.Query - response.Mode = "custom" + response.Mode = queryTypeCustom } } // If still no tables and mode is tables, use recommended - if response.Mode == "tables" && len(response.Tables) == 0 { + if response.Mode == resourceTables && len(response.Tables) == 0 { resourceType := azure.ResourceType(response.ResourceType) response.Tables = azure.GetRecommendedTables(resourceType) } @@ -183,15 +185,15 @@ func (s *Server) handleSaveLogConfig(w http.ResponseWriter, r *http.Request) { BadRequest(w, errModeRequired, nil) return } - if req.Mode != "tables" && req.Mode != "custom" { + if req.Mode != resourceTables && req.Mode != queryTypeCustom { BadRequest(w, "mode must be 'tables' or 'custom'", nil) return } - if req.Mode == "tables" && len(req.Tables) == 0 { + if req.Mode == resourceTables && len(req.Tables) == 0 { BadRequest(w, "tables required when mode is 'tables'", nil) return } - if req.Mode == "custom" && req.Query == "" { + if req.Mode == queryTypeCustom && req.Query == "" { BadRequest(w, "query required when mode is 'custom'", nil) return } @@ -214,7 +216,7 @@ func (s *Server) handleSaveLogConfig(w http.ResponseWriter, r *http.Request) { // Prepare tables and query based on mode var tables []string var query string - if req.Mode == "tables" { + if req.Mode == resourceTables { tables = req.Tables query = "" } else { diff --git a/cli/src/internal/dashboard/azure_setup.go b/cli/src/internal/dashboard/azure_setup.go index ed1ed16aa..72ae17a76 100644 --- a/cli/src/internal/dashboard/azure_setup.go +++ b/cli/src/internal/dashboard/azure_setup.go @@ -376,7 +376,7 @@ func (s *Server) checkDiagnosticSettings(ctx context.Context, serviceName, works // checkLogsFlowing queries for recent logs to verify log flow. // Returns ISO timestamp of most recent log, or empty string if no logs found. -func (s *Server) checkLogsFlowing(ctx context.Context, serviceName, workspaceID string, cred azcore.TokenCredential) string { +func (s *Server) checkLogsFlowing(ctx context.Context, serviceName, workspaceID string, _ azcore.TokenCredential) string { if workspaceID == "" { return "" } @@ -788,7 +788,7 @@ func (s *Server) handleAzureLogsVerify(w http.ResponseWriter, r *http.Request) { } } - level := "INFO" + var level string switch log.Level { case azure.LogLevelError: level = "ERROR" @@ -796,6 +796,8 @@ func (s *Server) handleAzureLogsVerify(w http.ResponseWriter, r *http.Request) { level = "WARN" case azure.LogLevelDebug: level = "DEBUG" + default: + level = "INFO" } samples = append(samples, VerifyLogsSample{ diff --git a/cli/src/internal/dashboard/classifications.go b/cli/src/internal/dashboard/classifications.go index 985d991fd..0c1170bb1 100644 --- a/cli/src/internal/dashboard/classifications.go +++ b/cli/src/internal/dashboard/classifications.go @@ -23,7 +23,7 @@ type ClassificationsResponse struct { } // handleGetClassifications returns all classifications from azure.yaml -func (s *Server) handleGetClassifications(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleGetClassifications(w http.ResponseWriter, _ *http.Request) { classificationsMu.RLock() defer classificationsMu.RUnlock() diff --git a/cli/src/internal/dashboard/client.go b/cli/src/internal/dashboard/client.go index 72ab10ba9..5f5663711 100644 --- a/cli/src/internal/dashboard/client.go +++ b/cli/src/internal/dashboard/client.go @@ -254,7 +254,7 @@ func (c *Client) GetWebSocketURL() string { // StreamLogs connects to the dashboard's log stream via WebSocket and sends log entries to the provided channel. // The serviceName parameter filters logs to a specific service (empty string for all services). -// The function blocks until the context is cancelled or an error occurs. +// The function blocks until the context is canceled or an error occurs. // The caller is responsible for closing the logs channel after StreamLogs returns. func (c *Client) StreamLogs(ctx context.Context, serviceName string, logs chan<- service.LogEntry) error { // Build WebSocket URL @@ -267,7 +267,10 @@ func (c *Client) StreamLogs(ctx context.Context, serviceName string, logs chan<- dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - conn, _, err := websocket.Dial(dialCtx, wsURL, nil) + conn, resp, err := websocket.Dial(dialCtx, wsURL, nil) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() //nolint:errcheck // best-effort cleanup + } if err != nil { return fmt.Errorf("failed to connect to log stream: %w", err) } @@ -281,7 +284,7 @@ func (c *Client) StreamLogs(ctx context.Context, serviceName string, logs chan<- default: var entry service.LogEntry if err := wsjson.Read(ctx, conn, &entry); err != nil { - // Check if context was cancelled + // Check if context was canceled if ctx.Err() != nil { return ctx.Err() } @@ -378,6 +381,8 @@ func (c *Client) GetAzureLogs(ctx context.Context, services []string, tail int, // GetAzureStatus retrieves the Azure connection status from the dashboard. // Checks if Azure logging is configured in azure.yaml. +// +//nolint:staticcheck // service.AzureStatus is deprecated but kept for backward compatibility func (c *Client) GetAzureStatus(ctx context.Context) (*service.AzureStatus, error) { // Check if Azure services are available (indicates Azure is configured) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/azure/services", nil) @@ -422,7 +427,7 @@ func (c *Client) GetAzureStatus(ctx context.Context) (*service.AzureStatus, erro } // StreamAzureLogs connects to the dashboard's Azure log stream via WebSocket. -// The function blocks until the context is cancelled or an error occurs. +// The function blocks until the context is canceled or an error occurs. func (c *Client) StreamAzureLogs(ctx context.Context, logs chan<- service.LogEntry) error { // Build WebSocket URL wsURL := c.GetWebSocketURL() + "/api/azure/logs/stream" @@ -431,7 +436,10 @@ func (c *Client) StreamAzureLogs(ctx context.Context, logs chan<- service.LogEnt dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - conn, _, err := websocket.Dial(dialCtx, wsURL, nil) + conn, resp, err := websocket.Dial(dialCtx, wsURL, nil) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() //nolint:errcheck // best-effort cleanup + } if err != nil { return fmt.Errorf("failed to connect to Azure log stream: %w", err) } diff --git a/cli/src/internal/dashboard/constants.go b/cli/src/internal/dashboard/constants.go index 9aa185ba2..81d70d969 100644 --- a/cli/src/internal/dashboard/constants.go +++ b/cli/src/internal/dashboard/constants.go @@ -27,7 +27,7 @@ const ( // Common messages MsgWorkspaceNotConfigured = "Log Analytics workspace not configured" - MsgAzureCredsNotAvailable = "Azure credentials not available. Run 'azd auth login' to authenticate." + MsgAzureCredsNotAvailable = "Azure credentials not available. Run 'azd auth login' to authenticate." //nolint:gosec // G101 - not a credential, just a status message constant MsgAuthFailed = "Authentication failed or expired. Run 'azd auth login' to re-authenticate." // Setup steps diff --git a/cli/src/internal/dashboard/health_stream.go b/cli/src/internal/dashboard/health_stream.go index ec65557ea..c80a72b06 100644 --- a/cli/src/internal/dashboard/health_stream.go +++ b/cli/src/internal/dashboard/health_stream.go @@ -35,6 +35,7 @@ const ( // HealthEventType represents the type of health event sent via SSE. type HealthEventType string +// HealthEventTypeHealth and related constants identify the SSE event payloads emitted by health streaming. const ( HealthEventTypeHealth HealthEventType = "health" HealthEventTypeChange HealthEventType = "health-change" diff --git a/cli/src/internal/dashboard/logs_config.go b/cli/src/internal/dashboard/logs_config.go index 94196bf45..01824162f 100644 --- a/cli/src/internal/dashboard/logs_config.go +++ b/cli/src/internal/dashboard/logs_config.go @@ -109,7 +109,7 @@ func savePreferencesWithClient(client azdconfig.ConfigClient, prefs UserPreferen // HTTP Handlers // handleGetPreferences returns user preferences -func (s *Server) handleGetPreferences(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleGetPreferences(w http.ResponseWriter, _ *http.Request) { client := s.getOrCreateConfigClient() prefs, err := loadPreferencesWithClient(client) if err != nil { diff --git a/cli/src/internal/dashboard/mode.go b/cli/src/internal/dashboard/mode.go index a0b01d4bd..00a3ad4cb 100644 --- a/cli/src/internal/dashboard/mode.go +++ b/cli/src/internal/dashboard/mode.go @@ -25,7 +25,7 @@ type ModeResponse struct { } // handleGetMode returns the current log source mode. -func (s *Server) handleGetMode(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleGetMode(w http.ResponseWriter, _ *http.Request) { // Get current mode from server state s.modeMu.RLock() currentMode := s.currentMode diff --git a/cli/src/internal/dashboard/server_handlers.go b/cli/src/internal/dashboard/server_handlers.go index 4eef07828..e68effd93 100644 --- a/cli/src/internal/dashboard/server_handlers.go +++ b/cli/src/internal/dashboard/server_handlers.go @@ -2,6 +2,7 @@ package dashboard import ( "compress/gzip" + "context" "encoding/json" "fmt" "html" @@ -46,7 +47,7 @@ func (s *Server) handleGetEnvironment(w http.ResponseWriter, r *http.Request) { // Detect if running in VS Code (desktop) vs browser-based Codespace // In VS Code desktop (including VS Code connected to Codespace), localhost URLs work natively // Only in browser-based Codespace do we need to transform localhost URLs - isVsCodeDesktop := runningOnVsCodeDesktop() + isVsCodeDesktop := runningOnVsCodeDesktop(r.Context()) // Get Azure environment name if available azureEnvName := os.Getenv("AZURE_ENV_NAME") @@ -70,7 +71,7 @@ func (s *Server) handleGetEnvironment(w http.ResponseWriter, r *http.Request) { // In browser-based Codespace, 'code --status' returns: // "The --status argument is not yet supported in browsers." // Reference: azure/azure-dev cli/azd/cmd/auth_login.go runningOnCodespacesBrowser -func runningOnVsCodeDesktop() bool { +func runningOnVsCodeDesktop(ctx context.Context) bool { // Check if running in Codespace first - if not, no need to check if os.Getenv("CODESPACES") != "true" { return false @@ -78,7 +79,7 @@ func runningOnVsCodeDesktop() bool { // Try to run 'code --status' to detect VS Code desktop vs browser // This command returns specific output in browser-based VS Code - cmd := exec.Command("code", "--status") + cmd := exec.CommandContext(ctx, "code", "--status") output, err := cmd.Output() if err != nil { // If code command fails or doesn't exist, we're likely in browser Codespace @@ -246,8 +247,8 @@ func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) { switch svc.Status { case "ready": statusClass = "ready" - case "error": - statusClass = "error" + case StatusError: + statusClass = StatusError } // Escape all user-controllable values to prevent XSS diff --git a/cli/src/internal/dashboard/websocket_concurrency_test.go b/cli/src/internal/dashboard/websocket_concurrency_test.go index 04ca25150..3eb68f430 100644 --- a/cli/src/internal/dashboard/websocket_concurrency_test.go +++ b/cli/src/internal/dashboard/websocket_concurrency_test.go @@ -310,7 +310,7 @@ func TestServer_ConcurrentBroadcasts(t *testing.T) { } // Start reading messages in background - go func(idx int, w *websocket.Conn) { + go func(_ int, w *websocket.Conn) { for { var msg map[string]interface{} if err := wsjson.Read(ctx, w, &msg); err != nil { diff --git a/cli/src/internal/dashboard/websocket_fixes_test.go b/cli/src/internal/dashboard/websocket_fixes_test.go index 567b16765..b21b2fec8 100644 --- a/cli/src/internal/dashboard/websocket_fixes_test.go +++ b/cli/src/internal/dashboard/websocket_fixes_test.go @@ -604,7 +604,7 @@ func TestConcurrentBroadcasts(t *testing.T) { _ = wsjson.Read(ctx, ws, &msg) // Read in background - go func(w *websocket.Conn, idx int) { + go func(w *websocket.Conn, _ int) { for { var m map[string]interface{} if err := wsjson.Read(ctx, w, &m); err != nil { diff --git a/cli/src/internal/detector/detector_dotnet.go b/cli/src/internal/detector/detector_dotnet.go index 614cec452..8beeb9fd7 100644 --- a/cli/src/internal/detector/detector_dotnet.go +++ b/cli/src/internal/detector/detector_dotnet.go @@ -23,13 +23,13 @@ func FindDotnetProjects(rootDir string) ([]types.DotnetProject, error) { err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } // Ensure we don't traverse outside rootDir absPath, err := filepath.Abs(path) if err != nil { - return nil + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } relPath, err := filepath.Rel(rootDir, absPath) if err != nil || strings.HasPrefix(relPath, "..") { @@ -93,7 +93,7 @@ func FindAppHost(rootDir string) (*types.AspireProject, error) { // Ensure we don't traverse outside rootDir absPath, err := filepath.Abs(path) if err != nil { - return nil + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } relPath, err := filepath.Rel(rootDir, absPath) if err != nil || strings.HasPrefix(relPath, "..") { @@ -113,7 +113,7 @@ func FindAppHost(rootDir string) (*types.AspireProject, error) { dir := filepath.Dir(path) matches, err := filepath.Glob(filepath.Join(dir, "*.csproj")) if err != nil { - return nil // Skip on error + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } if len(matches) > 0 { aspireProject = &types.AspireProject{ diff --git a/cli/src/internal/detector/detector_functions.go b/cli/src/internal/detector/detector_functions.go index c9df8bd1e..b137a0a8b 100644 --- a/cli/src/internal/detector/detector_functions.go +++ b/cli/src/internal/detector/detector_functions.go @@ -11,6 +11,12 @@ import ( types "github.com/jongio/azd-core/projecttype" ) +const ( + langPython = "python" + langDotnet = "dotnet" + langJava = "java" +) + // FindFunctionApps searches for Azure Functions projects (all variants including Logic Apps). // Only searches within rootDir and does not traverse outside it. // Returns all discovered Function Apps with their variant and language detected. @@ -34,7 +40,7 @@ func FindFunctionApps(rootDir string) ([]types.FunctionAppProject, error) { // Ensure we don't traverse outside rootDir absPath, err := filepath.Abs(path) if err != nil { - return nil + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } relPath, err := filepath.Rel(rootDir, absPath) if err != nil || strings.HasPrefix(relPath, "..") { @@ -99,10 +105,10 @@ func detectFunctionsVariantForDiscovery(dir string) string { // Check for Python Functions (function_app.py or requirements.txt + function.json) if fileExistsInDir(dir, "function_app.py") { - return "python" + return langPython } if fileExistsInDir(dir, "requirements.txt") && hasFunctionJsonInDir(dir) { - return "python" + return langPython } // Check for .NET Functions (.csproj with Azure Functions references) @@ -111,7 +117,7 @@ func detectFunctionsVariantForDiscovery(dir string) string { for _, csprojFile := range csprojFiles { if containsTextInFile(csprojFile, "Microsoft.Azure.Functions.Worker") || containsTextInFile(csprojFile, "Microsoft.NET.Sdk.Functions") { - return "dotnet" + return langDotnet } } } @@ -119,19 +125,19 @@ func detectFunctionsVariantForDiscovery(dir string) string { // Check for Java Functions (pom.xml or build.gradle with Azure Functions plugin) if fileExistsInDir(dir, "pom.xml") { if containsTextInFile(filepath.Join(dir, "pom.xml"), "azure-functions-maven-plugin") { - return "java" + return langJava } } if fileExistsInDir(dir, "build.gradle") { buildGradle := filepath.Join(dir, "build.gradle") if containsTextInFile(buildGradle, "azurefunctions") || containsTextInFile(buildGradle, "azure-functions") { - return "java" + return langJava } } if fileExistsInDir(dir, "build.gradle.kts") { buildGradleKts := filepath.Join(dir, "build.gradle.kts") if containsTextInFile(buildGradleKts, "azurefunctions") || containsTextInFile(buildGradleKts, "azure-functions") { - return "java" + return langJava } } @@ -148,11 +154,11 @@ func detectFunctionsLanguageForDiscovery(variant string, dir string) string { return "TypeScript" } return "JavaScript" - case "python": + case langPython: return "Python" - case "dotnet": + case langDotnet: return "C#" - case "java": + case langJava: return "Java" default: return "" diff --git a/cli/src/internal/detector/detector_node.go b/cli/src/internal/detector/detector_node.go index 9bedef27b..3748b2db8 100644 --- a/cli/src/internal/detector/detector_node.go +++ b/cli/src/internal/detector/detector_node.go @@ -11,6 +11,8 @@ import ( "github.com/jongio/azd-core/security" ) +const pkgNPM = "npm" + // FindNodeProjects searches for package.json files. // Only searches within rootDir and does not traverse outside it. // Detects npm/yarn/pnpm workspace configurations and marks workspace relationships. @@ -30,13 +32,13 @@ func FindNodeProjects(rootDir string) ([]types.NodeProject, error) { // First pass: find all package.json files and identify workspace roots err = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } // Ensure we don't traverse outside rootDir absPath, err := filepath.Abs(path) if err != nil { - return nil + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } relPath, err := filepath.Rel(rootDir, absPath) if err != nil || strings.HasPrefix(relPath, "..") { @@ -153,11 +155,11 @@ func DetectNodePackageManagerWithBoundaryAndSource(projectDir string, boundaryDi return PackageManagerInfo{Name: "yarn", Source: "yarn.lock"} } if _, err := os.Stat(filepath.Join(absDir, "package-lock.json")); err == nil { - return PackageManagerInfo{Name: "npm", Source: "package-lock.json"} + return PackageManagerInfo{Name: pkgNPM, Source: "package-lock.json"} } // Default to npm if no lock files found - return PackageManagerInfo{Name: "npm", Source: "package.json"} + return PackageManagerInfo{Name: pkgNPM, Source: "package.json"} } // GetPackageManagerFromPackageJSON reads package.json and extracts the packageManager field. @@ -202,7 +204,7 @@ func GetPackageManagerFromPackageJSON(projectDir string) string { // Validate it's a supported package manager switch pkgMgrName { - case "npm", "yarn", "pnpm": + case pkgNPM, "yarn", "pnpm": return pkgMgrName default: // Unsupported package manager, fall back to lock file detection diff --git a/cli/src/internal/detector/detector_python.go b/cli/src/internal/detector/detector_python.go index d99860e4d..5c7dcc728 100644 --- a/cli/src/internal/detector/detector_python.go +++ b/cli/src/internal/detector/detector_python.go @@ -48,7 +48,7 @@ func FindPythonProjects(rootDir string) ([]types.PythonProject, error) { // Ensure we don't traverse outside rootDir absPath, err := filepath.Abs(path) if err != nil { - return nil + return nil //nolint:nilerr // file not found is expected, means this detector doesn't match } relPath, err := filepath.Rel(rootDir, absPath) if err != nil || strings.HasPrefix(relPath, "..") { diff --git a/cli/src/internal/docker/exec.go b/cli/src/internal/docker/exec.go index ffa921693..24fac7c1d 100644 --- a/cli/src/internal/docker/exec.go +++ b/cli/src/internal/docker/exec.go @@ -2,6 +2,7 @@ package docker import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -23,7 +24,7 @@ func NewClient() *ExecClient { // IsAvailable checks if Docker is installed and running. func (c *ExecClient) IsAvailable() bool { - cmd := exec.Command("docker", "info") + cmd := exec.CommandContext(context.Background(), "docker", "info") err := cmd.Run() return err == nil } @@ -34,7 +35,7 @@ func (c *ExecClient) Pull(image string) error { return err } - cmd := exec.Command("docker", "pull", image) + cmd := exec.CommandContext(context.Background(), "docker", "pull", image) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -56,7 +57,7 @@ func (c *ExecClient) Run(config ContainerConfig) (string, error) { } args := buildRunArgs(config) - cmd := exec.Command("docker", args...) + cmd := exec.CommandContext(context.Background(), "docker", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -133,7 +134,7 @@ func (c *ExecClient) Stop(containerID string, timeoutSeconds int) error { } args := []string{"stop", "-t", fmt.Sprintf("%d", timeoutSeconds), containerID} - cmd := exec.Command("docker", args...) + cmd := exec.CommandContext(context.Background(), "docker", args...) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -155,7 +156,7 @@ func (c *ExecClient) Start(containerID string) error { return errors.New(errEmptyContainerID) } - cmd := exec.Command("docker", "start", containerID) + cmd := exec.CommandContext(context.Background(), "docker", "start", containerID) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -176,7 +177,7 @@ func (c *ExecClient) Remove(containerID string) error { return errors.New(errEmptyContainerID) } - cmd := exec.Command("docker", "rm", containerID) + cmd := exec.CommandContext(context.Background(), "docker", "rm", containerID) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -199,7 +200,7 @@ func (c *ExecClient) Logs(containerID string) (io.ReadCloser, error) { return nil, errors.New(errEmptyContainerID) } - cmd := exec.Command("docker", "logs", "-f", containerID) + cmd := exec.CommandContext(context.Background(), "docker", "logs", "-f", containerID) stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to get stdout pipe: %w", err) @@ -261,6 +262,7 @@ func (c *combinedReadCloser) Read(p []byte) (int, error) { return c.reader.Read(p) } +// Close shuts down the combined reader by closing the pipe and terminating the underlying command. func (c *combinedReadCloser) Close() error { // Close the pipe writer to unblock any pending reads if c.pipe != nil { @@ -291,7 +293,7 @@ func (c *ExecClient) Inspect(containerID string) (*Container, error) { return nil, errors.New(errEmptyContainerID) } - cmd := exec.Command("docker", "inspect", containerID) + cmd := exec.CommandContext(context.Background(), "docker", "inspect", containerID) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -331,7 +333,7 @@ func (c *ExecClient) IsRunning(containerID string) bool { return false } - cmd := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", containerID) + cmd := exec.CommandContext(context.Background(), "docker", "inspect", "-f", "{{.State.Running}}", containerID) var stdout bytes.Buffer cmd.Stdout = &stdout @@ -364,7 +366,7 @@ func (c *ExecClient) Exec(containerName string, command []string) (int, string, } args := append([]string{"exec", containerName}, command...) - cmd := exec.Command("docker", args...) + cmd := exec.CommandContext(context.Background(), "docker", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/cli/src/internal/executor/executor.go b/cli/src/internal/executor/executor.go index 4ced2ded0..2cef95d07 100644 --- a/cli/src/internal/executor/executor.go +++ b/cli/src/internal/executor/executor.go @@ -108,10 +108,12 @@ func StartCommandWithOutputMonitoring(ctx context.Context, name string, args []s cliout.Newline() // Wait for the command to complete (this blocks until the process exits or is killed) - if err := cmd.Wait(); err != nil { - // Ignore exit errors from Ctrl+C + waitErr := cmd.Wait() + if waitErr == nil { return nil } - - return nil + if ctx.Err() != nil { + return nil //nolint:nilerr // context cancellation from Ctrl+C is expected when stopping the command + } + return waitErr } diff --git a/cli/src/internal/healthcheck/checker.go b/cli/src/internal/healthcheck/checker.go index 9c8318887..127d8d4d9 100644 --- a/cli/src/internal/healthcheck/checker.go +++ b/cli/src/internal/healthcheck/checker.go @@ -23,6 +23,11 @@ import ( "golang.org/x/time/rate" ) +const ( + statusError = "error" + statusCompleted = "completed" +) + // HealthChecker performs individual health checks with circuit breaker and rate limiting. type HealthChecker struct { timeout time.Duration @@ -68,12 +73,15 @@ func (c *HealthChecker) getOrCreateCircuitBreaker(serviceName string) *gobreaker Interval: c.breakerTimeout, Timeout: c.breakerTimeout, ReadyToTrip: func(counts gobreaker.Counts) bool { - // Validate positive breakerFailures to prevent underflow/overflow - if c.breakerFailures < 0 { + // Validate breakerFailures to prevent underflow/overflow + if c.breakerFailures < 0 || int64(c.breakerFailures) > int64(^uint32(0)) { + return false + } + if counts.Requests == 0 { return false } failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) - return counts.Requests >= uint32(c.breakerFailures) && failureRatio >= 0.6 + return counts.Requests >= uint32(c.breakerFailures) && failureRatio >= 0.6 //nolint:gosec // G115 - breakerFailures bounds checked above }, OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) { log.Info(). @@ -783,7 +791,7 @@ func (c *HealthChecker) parseHealthResponseBody(body []byte, result *httpHealthC result.Status = HealthStatusHealthy case "degraded", "warning": result.Status = HealthStatusDegraded - case "unhealthy", "down", "error": + case "unhealthy", "down", statusError: result.Status = HealthStatusUnhealthy } } @@ -791,7 +799,7 @@ func (c *HealthChecker) parseHealthResponseBody(body []byte, result *httpHealthC } // performProcessHealthCheck handles health checks for process-type services. -func (c *HealthChecker) performProcessHealthCheck(ctx context.Context, svc serviceInfo, isInStartupGracePeriod bool) HealthCheckResult { +func (c *HealthChecker) performProcessHealthCheck(_ context.Context, svc serviceInfo, isInStartupGracePeriod bool) HealthCheckResult { result := HealthCheckResult{ ServiceName: svc.Name, Timestamp: time.Now(), @@ -873,7 +881,7 @@ func (c *HealthChecker) performBuildTaskHealthCheck(svc serviceInfo, isInStartup if svc.Mode == ServiceModeBuild { result.Details = map[string]interface{}{"state": "built", "exitCode": 0} } else { - result.Details = map[string]interface{}{"state": "completed", "exitCode": 0} + result.Details = map[string]interface{}{"state": statusCompleted, "exitCode": 0} } } else { result.Status = HealthStatusUnhealthy @@ -888,7 +896,7 @@ func (c *HealthChecker) performBuildTaskHealthCheck(svc serviceInfo, isInStartup if svc.Mode == ServiceModeBuild { result.Details = map[string]interface{}{"state": "built", "note": "exit code not captured"} } else { - result.Details = map[string]interface{}{"state": "completed", "note": "exit code not captured"} + result.Details = map[string]interface{}{"state": statusCompleted, "note": "exit code not captured"} } return result } @@ -916,7 +924,7 @@ func (c *HealthChecker) performOutputHealthCheck(svc serviceInfo, isInStartupGra if svc.ExitCode != nil { if *svc.ExitCode == 0 { result.Status = HealthStatusHealthy - result.Details["state"] = "completed" + result.Details["state"] = statusCompleted return result } result.Status = HealthStatusUnhealthy @@ -1004,7 +1012,7 @@ func suggestTCPErrorAction(err error, port int) string { } // suggestProcessErrorAction provides actionable suggestions for process check errors. -func suggestProcessErrorAction(pid int, isRunning bool, mode string) string { +func suggestProcessErrorAction(pid int, isRunning bool, _ string) string { if !isRunning { return fmt.Sprintf("Process %d not running. Check service logs and verify start command.", pid) } @@ -1051,7 +1059,7 @@ func parseErrorDetailsFromBody(body []byte) string { var jsonData map[string]interface{} if err := json.Unmarshal(body, &jsonData); err == nil { // Look for common error fields - for _, key := range []string{"error", "message", "detail", "details", "error_description"} { + for _, key := range []string{statusError, "message", "detail", "details", "error_description"} { if val, ok := jsonData[key]; ok { if str, ok := val.(string); ok && str != "" { return str diff --git a/cli/src/internal/healthcheck/metrics_test.go b/cli/src/internal/healthcheck/metrics_test.go index 0f72b5e54..98f861777 100644 --- a/cli/src/internal/healthcheck/metrics_test.go +++ b/cli/src/internal/healthcheck/metrics_test.go @@ -1,6 +1,7 @@ package healthcheck import ( + "context" "net/http" "testing" "time" @@ -196,7 +197,7 @@ func TestCreateMetricsServer_HealthEndpoint(t *testing.T) { server := CreateMetricsServer(0) // Use port 0 for testing // Create a test request to /health - req, err := http.NewRequest("GET", "/health", nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/health", nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } diff --git a/cli/src/internal/healthcheck/monitor.go b/cli/src/internal/healthcheck/monitor.go index cb86e9a73..bfc20041c 100644 --- a/cli/src/internal/healthcheck/monitor.go +++ b/cli/src/internal/healthcheck/monitor.go @@ -23,6 +23,12 @@ import ( "gopkg.in/yaml.v3" ) +const ( + statusRunning = "running" + statusBuilt = "built" + statusStarting = "starting" +) + var ( // metricsEnabled controls whether Prometheus metrics are recorded. // Uses atomic.Bool for thread-safe concurrent access from multiple goroutines. @@ -167,8 +173,12 @@ func (m *HealthMonitor) Check(ctx context.Context, serviceFilter []string) (*Hea if m.cache != nil { if cached, found := m.cache.Get(cacheKey); found { + report, ok := cached.(*HealthReport) + if !ok { + return nil, fmt.Errorf("cached health report for %q has unexpected type %T", cacheKey, cached) + } log.Debug().Str("key", cacheKey).Msg("Returning cached health report") - return cached.(*HealthReport), nil + return report, nil } } @@ -207,7 +217,7 @@ func (m *HealthMonitor) Check(ctx context.Context, serviceFilter []string) (*Hea ServiceName: svc.Name, Timestamp: time.Now(), Status: HealthStatusUnknown, - Error: "context cancelled before check started", + Error: "context canceled before check started", }} return } @@ -222,7 +232,7 @@ func (m *HealthMonitor) Check(ctx context.Context, serviceFilter []string) (*Hea ServiceName: svc.Name, Timestamp: time.Now(), Status: HealthStatusUnknown, - Error: "context cancelled", + Error: "context canceled", }} return } @@ -237,7 +247,7 @@ func (m *HealthMonitor) Check(ctx context.Context, serviceFilter []string) (*Hea ServiceName: svc.Name, Timestamp: time.Now(), Status: HealthStatusUnknown, - Error: "context cancelled", + Error: "context canceled", }} return } @@ -344,7 +354,7 @@ func (m *HealthMonitor) buildServiceList(azureYaml *service.AzureYaml, registere } } - var services []serviceInfo + services := make([]serviceInfo, 0, len(serviceMap)) for _, svc := range serviceMap { services = append(services, svc) } @@ -445,8 +455,8 @@ func (m *HealthMonitor) trackFailure(result *HealthCheckResult) { now := time.Now() m.lastSuccessTime[serviceName] = now result.LastSuccessTime = &now - default: - // For other statuses (degraded, starting, unknown), include current count without incrementing + case HealthStatusDegraded, HealthStatusStarting, HealthStatusUnknown: + // For non-terminal states, include current count without incrementing. if count, exists := m.failureCount[serviceName]; exists { result.ConsecutiveFailures = count } @@ -466,7 +476,7 @@ func (m *HealthMonitor) updateRegistry(results []HealthCheckResult) { continue } - if exists && currentEntry.Status == "running" && result.Status == HealthStatusStarting { + if exists && currentEntry.Status == statusRunning && result.Status == HealthStatusStarting { log.Debug(). Str("service", result.ServiceName). Msg("Keeping running status during health check grace period") @@ -480,36 +490,38 @@ func (m *HealthMonitor) updateRegistry(results []HealthCheckResult) { case HealthStatusHealthy: if details, ok := result.Details["state"].(string); ok { switch details { - case "built": - status = "built" - case "completed": - status = "completed" - case "building", "running": - status = "running" + case statusBuilt: + status = statusBuilt + case statusCompleted: + status = statusCompleted + case "building", statusRunning: + status = statusRunning case "failed": - status = "error" + status = statusError default: - status = "running" + status = statusRunning } } else { - status = "running" + status = statusRunning } case HealthStatusUnhealthy: - status = "error" + status = statusError case HealthStatusStarting: - status = "starting" - default: - status = "running" + status = statusStarting + case HealthStatusDegraded, HealthStatusUnknown: + status = statusRunning } } else { - status = "running" + status = statusRunning switch result.Status { case HealthStatusUnhealthy: - status = "error" + status = statusError case HealthStatusDegraded: status = "degraded" case HealthStatusStarting: - status = "starting" + status = statusStarting + case HealthStatusHealthy, HealthStatusUnknown: + status = statusRunning } } diff --git a/cli/src/internal/healthcheck/types.go b/cli/src/internal/healthcheck/types.go index 9491d5dc7..8f4517f5c 100644 --- a/cli/src/internal/healthcheck/types.go +++ b/cli/src/internal/healthcheck/types.go @@ -9,16 +9,34 @@ import ( core "github.com/jongio/azd-core/healthcheck" ) -// Re-export public types from azd-core/healthcheck. +// HealthStatus re-exports the core health status enumeration for monitored services. type HealthStatus = core.HealthStatus + +// HealthCheckType re-exports the core health check type enumeration. type HealthCheckType = core.HealthCheckType + +// HealthCheckResult re-exports the core health check result for an individual monitored service. type HealthCheckResult = core.HealthCheckResult + +// HealthReport re-exports the core health report returned by monitoring operations. type HealthReport = core.HealthReport + +// HealthSummary re-exports the aggregate core health summary across monitored services. type HealthSummary = core.HealthSummary + +// MonitorConfig re-exports the core monitor configuration used to set up health monitoring. type MonitorConfig = core.MonitorConfig + +// ServiceInfo re-exports the core service metadata used during health checks. type ServiceInfo = core.ServiceInfo + +// HealthCheckConfig re-exports the core probe configuration for a single health check. type HealthCheckConfig = core.HealthCheckConfig + +// HealthProfile re-exports the core named health profile definition loaded from configuration. type HealthProfile = core.HealthProfile + +// HealthProfiles re-exports the collection of named core health profiles. type HealthProfiles = core.HealthProfiles // Local aliases for backward compatibility with unexported references. diff --git a/cli/src/internal/installer/error_formatting_test.go b/cli/src/internal/installer/error_formatting_test.go index fb138d49c..e11d41e06 100644 --- a/cli/src/internal/installer/error_formatting_test.go +++ b/cli/src/internal/installer/error_formatting_test.go @@ -1,6 +1,7 @@ package installer import ( + "context" "fmt" "os/exec" "strings" @@ -71,7 +72,7 @@ func TestFormatNodeInstallError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a mock command that will fail - cmd := exec.Command("nonexistent-command-xyz-123") + cmd := exec.CommandContext(context.Background(), "nonexistent-command-xyz-123") cmd.Args = []string{tt.packageManager, "install"} // Create a mock error - we can't create ExitError directly, @@ -129,7 +130,7 @@ func TestFormatPythonInstallError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd := exec.Command("nonexistent-command-xyz-123") + cmd := exec.CommandContext(context.Background(), "nonexistent-command-xyz-123") cmd.Args = []string{tt.tool} cmdErr := fmt.Errorf("exit status %d", tt.exitCode) @@ -223,7 +224,7 @@ func TestFormatCommand(t *testing.T) { }{ { name: "normal_command", - cmd: exec.Command("pnpm", "install", "--prefer-offline"), + cmd: exec.CommandContext(context.Background(), "pnpm", "install", "--prefer-offline"), want: "pnpm install --prefer-offline", }, { diff --git a/cli/src/internal/installer/installer.go b/cli/src/internal/installer/installer.go index 1f49b4353..a7fba5142 100644 --- a/cli/src/internal/installer/installer.go +++ b/cli/src/internal/installer/installer.go @@ -3,6 +3,7 @@ package installer import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -86,9 +87,9 @@ func installNodeDependenciesWithWriter(project types.NodeProject, progressWriter if runtime.GOOS == "windows" { // Use cmd.exe /c to properly invoke .cmd files cmdArgs := append([]string{"/c", project.PackageManager}, args...) - cmd = exec.Command("cmd.exe", cmdArgs...) + cmd = exec.CommandContext(context.Background(), "cmd.exe", cmdArgs...) } else { - cmd = exec.Command(project.PackageManager, args...) + cmd = exec.CommandContext(context.Background(), project.PackageManager, args...) } cmd.Dir = project.Dir @@ -148,7 +149,7 @@ func restoreDotnetProjectWithWriter(project types.DotnetProject, progressWriter // Run restore with streaming output dir := filepath.Dir(project.Path) - cmd := exec.Command("dotnet", "restore", project.Path) + cmd := exec.CommandContext(context.Background(), "dotnet", "restore", project.Path) cmd.Dir = dir // Capture stderr for error reporting @@ -213,7 +214,7 @@ func setupWithUv(projectDir string, progressWriter io.Writer) error { cliout.Item("Installing dependencies into .venv (uv)...") } - cmd := exec.Command("uv", "sync", "--no-progress") + cmd := exec.CommandContext(context.Background(), "uv", "sync", "--no-progress") cmd.Dir = projectDir cmd.Env = os.Environ() // Inherit azd context (AZD_SERVER, AZD_ACCESS_TOKEN, AZURE_*) @@ -236,7 +237,7 @@ func setupWithUv(projectDir string, progressWriter io.Writer) error { if !cliout.IsJSON() && progressWriter == nil { cliout.Item("Creating virtual environment at .venv (uv)...") } - venvCmd := exec.Command("uv", "venv") + venvCmd := exec.CommandContext(context.Background(), "uv", "venv") venvCmd.Dir = projectDir venvCmd.Env = os.Environ() // Inherit azd context (AZD_SERVER, AZD_ACCESS_TOKEN, AZURE_*) @@ -260,7 +261,7 @@ func setupWithUv(projectDir string, progressWriter io.Writer) error { if !cliout.IsJSON() && progressWriter == nil { cliout.Item("Installing dependencies into .venv (uv pip)...") } - installCmd := exec.Command("uv", "pip", "install", "-r", "requirements.txt", "--no-progress") + installCmd := exec.CommandContext(context.Background(), "uv", "pip", "install", "-r", "requirements.txt", "--no-progress") installCmd.Dir = projectDir installCmd.Env = os.Environ() // Inherit azd context (AZD_SERVER, AZD_ACCESS_TOKEN, AZURE_*) @@ -301,7 +302,7 @@ func setupWithPoetry(projectDir string, progressWriter io.Writer) error { } // Check if virtual environment exists - checkCmd := exec.Command("poetry", "env", "info", "--path") + checkCmd := exec.CommandContext(context.Background(), "poetry", "env", "info", "--path") checkCmd.Dir = projectDir checkCmd.Env = os.Environ() // Inherit azd context (AZD_SERVER, AZD_ACCESS_TOKEN, AZURE_*) cmdOutput, err := checkCmd.CombinedOutput() @@ -319,7 +320,7 @@ func setupWithPoetry(projectDir string, progressWriter io.Writer) error { } // Install dependencies (use --no-root to avoid installing the package itself) - cmd := exec.Command("poetry", "install", "--no-root") + cmd := exec.CommandContext(context.Background(), "poetry", "install", "--no-root") cmd.Dir = projectDir cmd.Env = os.Environ() // Inherit azd context (AZD_SERVER, AZD_ACCESS_TOKEN, AZURE_*) @@ -356,7 +357,7 @@ func setupWithPip(projectDir string, progressWriter io.Writer) error { } // Create virtual environment - cmd := exec.Command("python", "-m", "venv", ".venv") + cmd := exec.CommandContext(context.Background(), "python", "-m", "venv", ".venv") cmd.Dir = projectDir cmd.Env = os.Environ() // Inherit azd context (AZD_SERVER, AZD_ACCESS_TOKEN, AZURE_*) @@ -396,7 +397,7 @@ func setupWithPip(projectDir string, progressWriter io.Writer) error { } // Run pip install with streaming output and optimizations - pipCmd := exec.Command(pipPath, "install", "-r", "requirements.txt", "--disable-pip-version-check", "--prefer-binary") + pipCmd := exec.CommandContext(context.Background(), pipPath, "install", "-r", "requirements.txt", "--disable-pip-version-check", "--prefer-binary") pipCmd.Dir = projectDir var stderrBuf bytes.Buffer @@ -499,7 +500,7 @@ type errorFormatter struct { } // formatInstallError creates a detailed error message using ecosystem-specific formatters -func formatInstallError(tool, projectDir string, cmd *exec.Cmd, cmdErr error, stderr string, formatter errorFormatter) error { +func formatInstallError(tool, _ string, cmd *exec.Cmd, cmdErr error, stderr string, formatter errorFormatter) error { // Extract exit code var exitCode int if exitErr, ok := cmdErr.(*exec.ExitError); ok { @@ -541,7 +542,7 @@ func formatInstallError(tool, projectDir string, cmd *exec.Cmd, cmdErr error, st } // nodeErrorFormatter provides Node.js-specific error formatting -func nodeErrorFormatter(packageManager, projectDir string) errorFormatter { +func nodeErrorFormatter(_ string, projectDir string) errorFormatter { return errorFormatter{ baseMessage: func(tool string) string { return fmt.Sprintf("failed to run %s install", tool) @@ -602,7 +603,7 @@ func formatDotnetRestoreError(projectPath, dir string, cmd *exec.Cmd, cmdErr err } // extractErrorDetails extracts the most relevant error lines from stderr -func extractErrorDetails(stderr, tool string) string { +func extractErrorDetails(stderr, _ string) string { if stderr == "" { return "" } @@ -807,10 +808,13 @@ func runWithRetry(cmd *exec.Cmd, stderrBuf *bytes.Buffer, maxRetries int) error // Calculate exponential backoff delay with bounds checking // Limit shift to prevent overflow (max 2^5 = 32 seconds) shiftAmount := attempt - 1 + if shiftAmount < 0 { + shiftAmount = 0 + } if shiftAmount > 5 { shiftAmount = 5 } - delay := time.Duration(1</dev/null | grep ':%d ' || netstat -tlnp 2>/dev/null | grep ':%d '", port, port)) + cmd = exec.CommandContext(context.Background(), "sh", "-c", fmt.Sprintf("ss -tlnp 2>/dev/null | grep ':%d ' || netstat -tlnp 2>/dev/null | grep ':%d '", port, port)) } output, err := cmd.Output() diff --git a/cli/src/internal/runner/aspire_test.go b/cli/src/internal/runner/aspire_test.go index 2f82e471d..57a53fd12 100644 --- a/cli/src/internal/runner/aspire_test.go +++ b/cli/src/internal/runner/aspire_test.go @@ -232,5 +232,5 @@ func (w *testLineWriter) Write(p []byte) (n int, err error) { } } - return n, nil + return n, nil //nolint:nilerr // partial line buffering is expected until a full line is available } diff --git a/cli/src/internal/runner/runner.go b/cli/src/internal/runner/runner.go index fef9308aa..3a6aea955 100644 --- a/cli/src/internal/runner/runner.go +++ b/cli/src/internal/runner/runner.go @@ -243,13 +243,17 @@ func RunDotnet(ctx context.Context, project types.DotnetProject) error { // For .sln files, we need to run from the directory // For .csproj files, we can pass the path directly args := []string{"run"} - dir := "" + var dir string if filepath.Ext(project.Path) == ".sln" { dir = filepath.Dir(project.Path) } else { args = append(args, "--project", project.Path) - dir, _ = os.Getwd() + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + dir = cwd } return executor.StartCommand(ctx, "dotnet", args, dir) @@ -270,8 +274,7 @@ func RunFunctionApp(ctx context.Context, project types.FunctionAppProject, port } // Variant-specific validation - switch project.Variant { - case "logicapps": + if project.Variant == "logicapps" { workflowsPath := filepath.Join(project.Dir, "workflows") if info, err := os.Stat(workflowsPath); err != nil || !info.IsDir() { return fmt.Errorf("logic Apps project missing workflows/ directory") diff --git a/cli/src/internal/service/azure_logs_config.go b/cli/src/internal/service/azure_logs_config.go index e4266f5fb..e74c39064 100644 --- a/cli/src/internal/service/azure_logs_config.go +++ b/cli/src/internal/service/azure_logs_config.go @@ -80,7 +80,9 @@ const ( ) // AzureStatus represents the Azure log streaming status. -// DEPRECATED: This type is preserved for backward compatibility with old clients. +// +// Deprecated: This type is preserved for backward compatibility with old clients. +// // New code should use /api/azure/logs/health endpoint instead. type AzureStatus struct { Mode LogMode `json:"mode"` diff --git a/cli/src/internal/service/config.go b/cli/src/internal/service/config.go index 2dba3033f..acc547116 100644 --- a/cli/src/internal/service/config.go +++ b/cli/src/internal/service/config.go @@ -67,7 +67,7 @@ func validateURL(urlStr, fieldName, serviceName string) error { } // Protocol enforcement - only http and https allowed - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + if parsedURL.Scheme != ServiceTypeHTTP && parsedURL.Scheme != "https" { return fmt.Errorf("%s for service '%s' must use http:// or https://, got %s://", fieldName, serviceName, parsedURL.Scheme) } diff --git a/cli/src/internal/service/constants.go b/cli/src/internal/service/constants.go index e75b1444f..94bc3de99 100644 --- a/cli/src/internal/service/constants.go +++ b/cli/src/internal/service/constants.go @@ -57,8 +57,8 @@ const ( // DefaultContextLines is the default number of context lines when not specified. DefaultContextLines = 3 - // Environment variable prefixes and patterns - // EnvServiceURLPrefix is the prefix for service URL environment variables (e.g., SERVICE_WEB_URL) + // EnvServiceURLPrefix is the prefix for service URL environment variables (for example, SERVICE_WEB_URL). EnvServiceURLPrefix = "SERVICE_" + // EnvServiceURLSuffix is the suffix appended to service URL environment variables. EnvServiceURLSuffix = "_URL" ) diff --git a/cli/src/internal/service/container_integration_test.go b/cli/src/internal/service/container_integration_test.go index 40dcea8ed..b451d6733 100644 --- a/cli/src/internal/service/container_integration_test.go +++ b/cli/src/internal/service/container_integration_test.go @@ -3,6 +3,7 @@ package service import ( + "context" "fmt" "net" "os/exec" @@ -20,7 +21,7 @@ import ( func checkDockerAvailable(t *testing.T) { t.Helper() - cmd := exec.Command("docker", "version") + cmd := exec.CommandContext(context.Background(), "docker", "version") if err := cmd.Run(); err != nil { t.Skip("Docker not available, skipping integration tests") } @@ -310,7 +311,8 @@ func TestContainerService_Cleanup(t *testing.T) { // checkTCPPort is a helper function to verify TCP port connectivity. func checkTCPPort(host string, port int, timeout time.Duration) bool { addr := fmt.Sprintf("%s:%d", host, port) - conn, err := net.DialTimeout("tcp", addr, timeout) + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(context.Background(), "tcp", addr) if err != nil { return false } diff --git a/cli/src/internal/service/detector.go b/cli/src/internal/service/detector.go index 2c0b48cc3..316d11e50 100644 --- a/cli/src/internal/service/detector.go +++ b/cli/src/internal/service/detector.go @@ -51,9 +51,9 @@ func DetectServiceRuntime(serviceName string, service Service, usedPorts map[int } // Determine default health check type based on service configuration - defaultHealthCheckType := "http" + defaultHealthCheckType := ServiceTypeHTTP if service.IsHealthcheckDisabled() { - defaultHealthCheckType = "none" + defaultHealthCheckType = watchModeNone } else if service.Healthcheck != nil && service.Healthcheck.Type != "" { defaultHealthCheckType = service.Healthcheck.Type } @@ -160,7 +160,7 @@ func detectContainerRuntime(serviceName string, service Service, usedPorts map[i // Determine default health check type - TCP for containers (port connectivity) defaultHealthCheckType := "tcp" if service.IsHealthcheckDisabled() { - defaultHealthCheckType = "none" + defaultHealthCheckType = watchModeNone } else if service.Healthcheck != nil && service.Healthcheck.Type != "" { defaultHealthCheckType = service.Healthcheck.Type } @@ -184,7 +184,7 @@ func detectContainerRuntime(serviceName string, service Service, usedPorts map[i // Should add a dedicated Image field to ServiceRuntime struct for container-based services. runtime.Command = image runtime.Language = "container" - runtime.Framework = "docker" + runtime.Framework = packageMgrDocker // Copy environment variables from service config for key, value := range service.GetEnvironment() { @@ -293,13 +293,13 @@ func isBuildCommand(cmd string, language string) bool { // Language-specific build commands buildCommands := map[string][]string{ - "TypeScript": {"tsc", "npm run build", "pnpm build", "yarn build", "bun build"}, - "JavaScript": {"npm run build", "pnpm build", "yarn build", "bun build", "webpack", "rollup", "esbuild"}, - "Go": {"go build", "go install"}, - ".NET": {"dotnet build", "dotnet publish"}, - "Rust": {"cargo build"}, - "Java": {"mvn package", "mvn compile", "gradle build", "gradle assemble"}, - "Python": {"python setup.py build", "pip wheel"}, + langTypeScript: {"tsc", "npm run build", "pnpm build", "yarn build", "bun build"}, + langNameJavaScript: {"npm run build", "pnpm build", "yarn build", "bun build", "webpack", "rollup", "esbuild"}, + "Go": {"go build", "go install"}, + langNameDotNet: {"dotnet build", "dotnet publish"}, + langNameRust: {"cargo build"}, + langNameJava: {"mvn package", "mvn compile", "gradle build", "gradle assemble"}, + langNamePython: {"python setup.py build", "pip wheel"}, } // Check language-specific build commands @@ -318,9 +318,9 @@ func isBuildCommand(cmd string, language string) bool { } // hasWatchModeIndicators checks project structure for watch mode configuration. -func hasWatchModeIndicators(projectDir string, language string, packageManager string) bool { +func hasWatchModeIndicators(projectDir string, language string, _ string) bool { switch language { - case "TypeScript", "JavaScript": + case langTypeScript, langNameJavaScript: // Check package.json for watch scripts return hasWatchScriptInPackageJSON(projectDir) case "Go": @@ -328,10 +328,10 @@ func hasWatchModeIndicators(projectDir string, language string, packageManager s return fileExists(projectDir, "air.toml") || fileExists(projectDir, ".air.toml") || fileExists(projectDir, "reflex.conf") - case ".NET": + case langNameDotNet: // .NET watch is typically in command, not config return false - case "Python": + case langNamePython: // Check for watchdog or watchfiles in requirements return containsWatchDependency(projectDir) default: diff --git a/cli/src/internal/service/detector_commands.go b/cli/src/internal/service/detector_commands.go index ba6f175c0..73e504368 100644 --- a/cli/src/internal/service/detector_commands.go +++ b/cli/src/internal/service/detector_commands.go @@ -10,6 +10,8 @@ import ( "github.com/jongio/azd-core/security" ) +const frameworkSpringBoot = "Spring Boot" + // buildRunCommand builds the command and arguments to run the service. // // Priority: @@ -60,7 +62,7 @@ func buildFrameworkCommand(runtime *ServiceRuntime, projectDir, runtimeMode stri // Handle Python frameworks with venv support pythonFrameworks := map[string]struct{}{ "Django": {}, "FastAPI": {}, "Flask": {}, - "Streamlit": {}, "Gradio": {}, "Python": {}, + "Streamlit": {}, "Gradio": {}, langNamePython: {}, } if _, isPython := pythonFrameworks[runtime.Framework]; isPython { @@ -100,14 +102,14 @@ func buildFrameworkCommand(runtime *ServiceRuntime, projectDir, runtimeMode stri case "Aspire": return buildDotNetCommand(runtime, projectDir, runtimeMode, true) - case "ASP.NET Core", ".NET": + case "ASP.NET Core", langNameDotNet: return buildDotNetCommand(runtime, projectDir, runtimeMode, false) - case "Spring Boot": + case frameworkSpringBoot: buildJavaCommand(runtime, true) return nil - case "Java": + case langNameJava: buildJavaCommand(runtime, false) return nil @@ -115,7 +117,7 @@ func buildFrameworkCommand(runtime *ServiceRuntime, projectDir, runtimeMode stri runtime.Command = "go" runtime.Args = []string{"run", "."} - case "Rust": + case langNameRust: runtime.Command = "cargo" runtime.Args = []string{"run"} @@ -123,7 +125,7 @@ func buildFrameworkCommand(runtime *ServiceRuntime, projectDir, runtimeMode stri runtime.Command = "php" runtime.Args = []string{"artisan", "serve", "--host=0.0.0.0", "--port=" + fmt.Sprintf("%d", runtime.Port)} - case "PHP": + case langNamePHP: runtime.Command = "php" runtime.Args = []string{"-S", fmt.Sprintf("0.0.0.0:%d", runtime.Port)} @@ -136,7 +138,7 @@ func buildFrameworkCommand(runtime *ServiceRuntime, projectDir, runtimeMode stri // buildDotNetCommand configures a .NET service runtime command. func buildDotNetCommand(runtime *ServiceRuntime, projectDir, runtimeMode string, isAspire bool) error { - runtime.Command = "dotnet" + runtime.Command = langDotnet csprojFiles, _ := filepath.Glob(filepath.Join(projectDir, "*.csproj")) if len(csprojFiles) > 0 { @@ -195,12 +197,9 @@ func getPythonVenvPath(projectDir string) string { } // resolvePythonEntrypoint resolves and validates the Python entrypoint file. -// Returns the entrypoint filename, with fallback to auto-detection if not provided. -func resolvePythonEntrypoint(projectDir, entrypoint string) (string, error) { - appFile := entrypoint - if appFile == "" { - appFile = findPythonAppFile(projectDir) - } +// Returns the auto-detected entrypoint filename. +func resolvePythonEntrypoint(projectDir, _ string) (string, error) { + appFile := findPythonAppFile(projectDir) if err := validatePythonEntrypoint(projectDir, appFile); err != nil { return "", err } @@ -254,7 +253,7 @@ func buildPythonDefaultCommand(runtime *ServiceRuntime, projectDir, pythonCmd st runtime.Args = []string{"-m", "streamlit", "run", appFile + ".py", "--server.port", fmt.Sprintf("%d", runtime.Port)} return nil - case "Gradio", "Python": + case "Gradio", langNamePython: appFile, err := resolvePythonEntrypoint(projectDir, "") // Auto-detect if err != nil { return fmt.Errorf("%s: %w", runtime.Framework, err) @@ -278,7 +277,7 @@ func configureHealthCheck(runtime *ServiceRuntime) { "Aspire": {"/", "Now listening on"}, "Next.js": {"/", "ready on"}, "Django": {"/", "Starting development server"}, - "Spring Boot": {"/actuator/health", "Started"}, + frameworkSpringBoot: {"/actuator/health", "Started"}, "FastAPI": {"/docs", ""}, } diff --git a/cli/src/internal/service/detector_frameworks.go b/cli/src/internal/service/detector_frameworks.go index 659fcbe82..06cc507fe 100644 --- a/cli/src/internal/service/detector_frameworks.go +++ b/cli/src/internal/service/detector_frameworks.go @@ -7,6 +7,20 @@ import ( "github.com/jongio/azd-app/cli/src/internal/detector" ) +const ( + frameworkDocker = "Docker" + packageMgrDocker = "docker" + langNameJavaScript = "JavaScript" + langTypeScript = "TypeScript" + langNamePython = "Python" + langNameDotNet = ".NET" + langNameJava = "Java" + langNameRust = "Rust" + langNamePHP = "PHP" + watchModeNone = "none" + langDotnet = "dotnet" +) + // detectLanguage determines the programming language used by the service. func detectLanguage(projectDir string, host string) (string, error) { // Define language detection rules in priority order @@ -14,32 +28,32 @@ func detectLanguage(projectDir string, host string) (string, error) { name string checkFunc func() bool }{ - {"TypeScript", func() bool { + {langTypeScript, func() bool { return fileExists(projectDir, "package.json") && fileExists(projectDir, "tsconfig.json") }}, - {"JavaScript", func() bool { + {langNameJavaScript, func() bool { return fileExists(projectDir, "package.json") }}, - {"Python", func() bool { + {langNamePython, func() bool { return fileExists(projectDir, "requirements.txt") || fileExists(projectDir, "pyproject.toml") || fileExists(projectDir, "poetry.lock") || fileExists(projectDir, "uv.lock") }}, - {".NET", func() bool { + {langNameDotNet, func() bool { return hasFileWithExt(projectDir, ".csproj") || hasFileWithExt(projectDir, ".sln") || hasFileWithExt(projectDir, ".fsproj") }}, - {"Java", func() bool { + {langNameJava, func() bool { return fileExists(projectDir, "pom.xml") || fileExists(projectDir, "build.gradle") || fileExists(projectDir, "build.gradle.kts") }}, {"Go", func() bool { return fileExists(projectDir, "go.mod") }}, - {"Rust", func() bool { return fileExists(projectDir, "Cargo.toml") }}, - {"PHP", func() bool { return fileExists(projectDir, "composer.json") }}, - {"Docker", func() bool { + {langNameRust, func() bool { return fileExists(projectDir, "Cargo.toml") }}, + {langNamePHP, func() bool { return fileExists(projectDir, "composer.json") }}, + {frameworkDocker, func() bool { return fileExists(projectDir, "Dockerfile") || fileExists(projectDir, "docker-compose.yml") }}, } @@ -53,7 +67,7 @@ func detectLanguage(projectDir string, host string) (string, error) { // Fallback: use host type as hint if host == "containerapp" || host == "aks" { - return "Docker", nil + return frameworkDocker, nil } return "", errCouldNotDetectLanguage(projectDir) @@ -62,22 +76,22 @@ func detectLanguage(projectDir string, host string) (string, error) { // detectFrameworkAndPackageManager detects the specific framework and package manager. func detectFrameworkAndPackageManager(projectDir string, language string) (string, string, error) { switch language { - case "TypeScript", "JavaScript": + case langTypeScript, langNameJavaScript: return detectNodeFramework(projectDir) - case "Python": + case langNamePython: return detectPythonFramework(projectDir) - case ".NET": + case langNameDotNet: return detectDotNetFramework(projectDir) - case "Java": + case langNameJava: return detectJavaFramework(projectDir) case "Go": return "Go", "go", nil - case "Rust": - return "Rust", "cargo", nil - case "PHP": + case langNameRust: + return langNameRust, "cargo", nil + case langNamePHP: return detectPHPFramework(projectDir) - case "Docker": - return "Docker", "docker", nil + case frameworkDocker: + return frameworkDocker, packageMgrDocker, nil default: return language, "", nil } @@ -150,14 +164,14 @@ func detectPythonFramework(projectDir string) (string, string, error) { } // Default to generic Python - return "Python", packageManager, nil + return langNamePython, packageManager, nil } // detectDotNetFramework detects .NET framework. func detectDotNetFramework(projectDir string) (string, string, error) { // Check for Aspire if fileExists(projectDir, "AppHost.cs") { - return "Aspire", "dotnet", nil + return "Aspire", langDotnet, nil } // Check for ASP.NET Core @@ -166,13 +180,13 @@ func detectDotNetFramework(projectDir string) (string, string, error) { csprojFiles, _ := filepath.Glob(filepath.Join(projectDir, "*.csproj")) for _, csprojFile := range csprojFiles { if containsText(csprojFile, "Microsoft.NET.Sdk.Web") { - return "ASP.NET Core", "dotnet", nil + return "ASP.NET Core", langDotnet, nil } } } // Default to generic .NET - return ".NET", "dotnet", nil + return langNameDotNet, langDotnet, nil } // detectJavaFramework detects Java framework. @@ -184,21 +198,21 @@ func detectJavaFramework(projectDir string) (string, string, error) { // Check for Spring Boot in pom.xml if fileExists(projectDir, "pom.xml") && containsText(filepath.Join(projectDir, "pom.xml"), "spring-boot") { - return "Spring Boot", packageManager, nil + return frameworkSpringBoot, packageManager, nil } // Check for frameworks in build.gradle if fileExists(projectDir, "build.gradle") { buildGradle := filepath.Join(projectDir, "build.gradle") if containsText(buildGradle, "spring-boot") { - return "Spring Boot", packageManager, nil + return frameworkSpringBoot, packageManager, nil } if containsText(buildGradle, "quarkus") { return "Quarkus", packageManager, nil } } - return "Java", packageManager, nil + return langNameJava, packageManager, nil } // detectPHPFramework detects PHP framework. @@ -207,5 +221,5 @@ func detectPHPFramework(projectDir string) (string, string, error) { return "Laravel", "composer", nil } - return "PHP", "composer", nil + return langNamePHP, "composer", nil } diff --git a/cli/src/internal/service/detector_helpers.go b/cli/src/internal/service/detector_helpers.go index 053739693..81b84e045 100644 --- a/cli/src/internal/service/detector_helpers.go +++ b/cli/src/internal/service/detector_helpers.go @@ -117,7 +117,7 @@ func normalizeLanguage(language string) string { case "js", "javascript", "node", "nodejs", "node.js": return "JavaScript" case "ts", "typescript": - return "TypeScript" + return langTypeScript case "py", "python": return "Python" case "cs", "csharp", "c#": diff --git a/cli/src/internal/service/executor.go b/cli/src/internal/service/executor.go index f618dda71..d358960c5 100644 --- a/cli/src/internal/service/executor.go +++ b/cli/src/internal/service/executor.go @@ -53,9 +53,9 @@ func StartService(runtime *ServiceRuntime, env map[string]string, projectDir str } // createServiceCommand creates an exec.Cmd for the service. -func createServiceCommand(runtime *ServiceRuntime, env map[string]string) (*exec.Cmd, error) { +func createServiceCommand(runtime *ServiceRuntime, env map[string]string) (*exec.Cmd, error) { //nolint:unparam // return value kept for future use/interface conformance // #nosec G204 -- Command and args come from azure.yaml service configuration, validated by service package - cmd := exec.Command(runtime.Command, runtime.Args...) + cmd := exec.CommandContext(context.Background(), runtime.Command, runtime.Args...) cmd.Dir = runtime.WorkingDir // Build environment variable list ensuring azd context is preserved. @@ -134,7 +134,7 @@ func StopServiceGraceful(process *ServiceProcess, timeout time.Duration) error { // /T = Kill child processes (tree kill) // /PID = Target process ID // #nosec G204 -- PID is from os.Process which is a validated integer - cmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", process.Process.Pid)) + cmd := exec.CommandContext(context.Background(), "taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", process.Process.Pid)) if err := cmd.Run(); err != nil { // taskkill returns error if process already exited, which is fine slog.Debug("taskkill completed with error (process may have already exited)", diff --git a/cli/src/internal/service/functions.go b/cli/src/internal/service/functions.go index b08e6e5e1..ddc28b8fd 100644 --- a/cli/src/internal/service/functions.go +++ b/cli/src/internal/service/functions.go @@ -284,7 +284,7 @@ func detectFunctionsLanguage(variant FunctionsVariant, projectDir string) (strin case FunctionsVariantNodeJS: // Check if it's TypeScript if fileExists(projectDir, "tsconfig.json") { - return "TypeScript", nil + return langTypeScript, nil } return "JavaScript", nil diff --git a/cli/src/internal/service/health.go b/cli/src/internal/service/health.go index 76182740d..e2cb59c0a 100644 --- a/cli/src/internal/service/health.go +++ b/cli/src/internal/service/health.go @@ -2,6 +2,7 @@ package service import ( + "context" "errors" "fmt" "log/slog" @@ -54,7 +55,7 @@ func PerformHealthCheck(process *ServiceProcess) error { var err error switch config.Type { - case "http": + case ServiceTypeHTTP: err = HTTPHealthCheck(process.Port, config.Path) case "tcp": err = PortHealthCheck(process.Port) @@ -131,8 +132,14 @@ func HTTPHealthCheck(port int, path string) error { }, } + ctx := context.Background() + // Try HEAD request first (lightweight) - resp, err := client.Head(url) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return fmt.Errorf("failed to create HTTP HEAD request: %w", err) + } + resp, err := client.Do(req) if err == nil { defer SafeClose(resp.Body, "HEAD response body") // Accept any 2xx or 3xx status code @@ -142,7 +149,11 @@ func HTTPHealthCheck(port int, path string) error { } // If HEAD fails or returns error code, try GET - resp, err = client.Get(url) + req, err = http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create HTTP GET request: %w", err) + } + resp, err = client.Do(req) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) } @@ -159,7 +170,8 @@ func HTTPHealthCheck(port int, path string) error { // PortHealthCheck verifies that a port is listening. func PortHealthCheck(port int) error { address := fmt.Sprintf("localhost:%d", port) - conn, err := net.DialTimeout("tcp", address, ConnectionTimeout) + dialer := net.Dialer{Timeout: ConnectionTimeout} + conn, err := dialer.DialContext(context.Background(), "tcp", address) if err != nil { return fmt.Errorf("port %d not listening: %w", port, err) } @@ -216,7 +228,8 @@ func TryHTTPHealthCheck(port int, path string) bool { // IsPortListening checks if a port is currently listening. func IsPortListening(port int) bool { address := fmt.Sprintf("localhost:%d", port) - conn, err := net.DialTimeout("tcp", address, PortCheckTimeout) + dialer := net.Dialer{Timeout: PortCheckTimeout} + conn, err := dialer.DialContext(context.Background(), "tcp", address) if err != nil { return false } diff --git a/cli/src/internal/service/health_test.go b/cli/src/internal/service/health_test.go index c4bf96677..d80d6b9b7 100644 --- a/cli/src/internal/service/health_test.go +++ b/cli/src/internal/service/health_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "net" "net/http" "net/http/httptest" @@ -333,10 +334,10 @@ func TestPerformHealthCheck_ProcessType(t *testing.T) { var cmd *exec.Cmd if runtime.GOOS == "windows" { // On Windows, use ping with count to wait - cmd = exec.Command("ping", "-n", "10", "127.0.0.1") + cmd = exec.CommandContext(context.Background(), "ping", "-n", "10", "127.0.0.1") } else { // On Unix, use sleep - cmd = exec.Command("sleep", "5") + cmd = exec.CommandContext(context.Background(), "sleep", "5") } if err := cmd.Start(); err != nil { diff --git a/cli/src/internal/service/health_windows.go b/cli/src/internal/service/health_windows.go index 8ae8762fe..ea361d0ed 100644 --- a/cli/src/internal/service/health_windows.go +++ b/cli/src/internal/service/health_windows.go @@ -40,7 +40,7 @@ func processIsRunning(pid int) error { ret, _, err := getExitCodeProcess.Call( uintptr(handle), - uintptr(unsafe.Pointer(&exitCode)), + uintptr(unsafe.Pointer(&exitCode)), //nolint:gosec // G103 - unsafe required for Windows process handle operations ) if ret == 0 { return fmt.Errorf("failed to get exit code for process %d: %w", pid, err) diff --git a/cli/src/internal/service/logbuffer.go b/cli/src/internal/service/logbuffer.go index 2840a6af8..3b909f8e3 100644 --- a/cli/src/internal/service/logbuffer.go +++ b/cli/src/internal/service/logbuffer.go @@ -354,7 +354,9 @@ func (lb *LogBuffer) GetLogsWithContext(level LogLevel, limit int, contextLines } // GetErrors is deprecated: use GetLogsWithContext instead. +// // Deprecated: This method exists for backward compatibility. +// // Parameters: // - limit: maximum number of error entries to return (0 = no limit) // - contextLines: number of log lines before and after each error (0-10) diff --git a/cli/src/internal/service/logmanager.go b/cli/src/internal/service/logmanager.go index f18eefd77..62bc58333 100644 --- a/cli/src/internal/service/logmanager.go +++ b/cli/src/internal/service/logmanager.go @@ -203,7 +203,9 @@ func (lm *LogManager) GetAllLogsWithContext(serviceName string, level LogLevel, } // GetAllErrors is deprecated: use GetAllLogsWithContext instead. +// // Deprecated: This method exists for backward compatibility. +// // Parameters: // - serviceName: filter to specific service (empty = all services) // - limit: maximum total errors to return (0 = default 50) diff --git a/cli/src/internal/service/logmanager_test.go b/cli/src/internal/service/logmanager_test.go index 7515e9562..d0b2159f0 100644 --- a/cli/src/internal/service/logmanager_test.go +++ b/cli/src/internal/service/logmanager_test.go @@ -327,7 +327,7 @@ func TestLogManagerConcurrency(t *testing.T) { // Test concurrent buffer creation done := make(chan bool) for i := 0; i < 10; i++ { - go func(n int) { + go func(_ int) { _, err := lm.CreateBuffer("concurrent-test", 1000, false) if err != nil { t.Errorf("concurrent CreateBuffer failed: %v", err) diff --git a/cli/src/internal/service/operation_manager.go b/cli/src/internal/service/operation_manager.go index bb0e8fb2b..26cf28e4b 100644 --- a/cli/src/internal/service/operation_manager.go +++ b/cli/src/internal/service/operation_manager.go @@ -26,6 +26,7 @@ const ( // OperationType represents the type of service operation. type OperationType string +// OpStart and related constants identify the service operations coordinated by the operation manager. const ( OpStart OperationType = "start" OpStop OperationType = "stop" diff --git a/cli/src/internal/service/port.go b/cli/src/internal/service/port.go index 18ae2f80c..6955dd607 100644 --- a/cli/src/internal/service/port.go +++ b/cli/src/internal/service/port.go @@ -2,6 +2,7 @@ package service import ( + "context" "encoding/json" "fmt" "net" @@ -481,7 +482,8 @@ func findAvailablePort(startPort int, usedPorts map[int]bool) (int, error) { } // Try to bind to the port to check if it's available - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + lc := net.ListenConfig{} + listener, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err == nil { if closeErr := listener.Close(); closeErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to close listener: %v\n", closeErr) @@ -495,7 +497,8 @@ func findAvailablePort(startPort int, usedPorts map[int]bool) (int, error) { // IsPortAvailable checks if a port is available. func IsPortAvailable(port int) bool { - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + lc := net.ListenConfig{} + listener, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err != nil { return false } diff --git a/cli/src/internal/service/types.go b/cli/src/internal/service/types.go index 510ce2ab7..4fc776c36 100644 --- a/cli/src/internal/service/types.go +++ b/cli/src/internal/service/types.go @@ -620,6 +620,7 @@ const ( // LogLevel represents the severity of a log message. type LogLevel int +// LogLevelInfo and related constants define the severity levels used for service log entries. const ( LogLevelInfo LogLevel = iota LogLevelWarn @@ -662,10 +663,12 @@ type LogEntryWithContext struct { } // ErrorEntry is deprecated: use LogEntryWithContext instead. +// // Deprecated: This type alias exists for backward compatibility. type ErrorEntry = LogEntryWithContext // ErrorContext is deprecated: use LogContext instead. +// // Deprecated: This type alias exists for backward compatibility. type ErrorContext = LogContext diff --git a/cli/src/internal/service/venv_integration_test.go b/cli/src/internal/service/venv_integration_test.go index e150b6537..d7cee7f2d 100644 --- a/cli/src/internal/service/venv_integration_test.go +++ b/cli/src/internal/service/venv_integration_test.go @@ -3,6 +3,7 @@ package service_test import ( + "context" "os" "os/exec" "path/filepath" @@ -31,7 +32,7 @@ func TestPythonVenvIntegration(t *testing.T) { } else if _, err := exec.LookPath("python"); err == nil { pythonCmd = "python" // Verify it's actually Python and not a Windows Store stub - verifyCmd := exec.Command(pythonCmd, "--version") + verifyCmd := exec.CommandContext(context.Background(), pythonCmd, "--version") if err := verifyCmd.Run(); err != nil { t.Skipf("Python found but not functional: %v", err) } @@ -134,7 +135,7 @@ services: // Create real venv t.Logf("Creating virtual environment in %s", tmpDir) - venvCmd := exec.Command(pythonCmd, "-m", "venv", ".venv") + venvCmd := exec.CommandContext(context.Background(), pythonCmd, "-m", "venv", ".venv") venvCmd.Dir = tmpDir venvCmd.Stdout = os.Stdout venvCmd.Stderr = os.Stderr @@ -156,7 +157,7 @@ services: // Install dependencies t.Logf("Installing dependencies: %v", tt.dependencies) installArgs := append([]string{"install", "--quiet"}, tt.dependencies...) - installCmd := exec.Command(pipPath, installArgs...) + installCmd := exec.CommandContext(context.Background(), pipPath, installArgs...) installCmd.Dir = tmpDir installCmd.Stdout = os.Stdout installCmd.Stderr = os.Stderr @@ -226,7 +227,7 @@ services: // Verify the module is actually installed in venv t.Logf("Verifying %s is installed in venv", tt.expectedModule) venvPython := runtimeInfo.Command - checkCmd := exec.Command(venvPython, "-c", "import "+tt.expectedModule) + checkCmd := exec.CommandContext(context.Background(), venvPython, "-c", "import "+tt.expectedModule) checkCmd.Dir = tmpDir if err := checkCmd.Run(); err != nil { t.Errorf("Module %s not importable from venv Python: %v", tt.expectedModule, err) @@ -251,7 +252,7 @@ func TestPythonVenvFallback(t *testing.T) { pythonCmd = "python3" } - verifyCmd := exec.Command(pythonCmd, "--version") + verifyCmd := exec.CommandContext(context.Background(), pythonCmd, "--version") if err := verifyCmd.Run(); err != nil { t.Skipf("Python not available or not functional: %v", err) } diff --git a/cli/src/internal/serviceinfo/environment_test.go b/cli/src/internal/serviceinfo/environment_test.go index ee40651d6..2067aef45 100644 --- a/cli/src/internal/serviceinfo/environment_test.go +++ b/cli/src/internal/serviceinfo/environment_test.go @@ -109,7 +109,7 @@ func TestRefreshEnvironmentCache_ConcurrentAccess(t *testing.T) { // Concurrent refreshes for i := 0; i < 10; i++ { wg.Add(1) - go func(id int) { + go func(_ int) { defer wg.Done() for j := 0; j < iterations; j++ { RefreshEnvironmentCache() @@ -120,7 +120,7 @@ func TestRefreshEnvironmentCache_ConcurrentAccess(t *testing.T) { // Concurrent reads for i := 0; i < 10; i++ { wg.Add(1) - go func(id int) { + go func(_ int) { defer wg.Done() for j := 0; j < iterations; j++ { getAzureEnvironmentValues() diff --git a/cli/src/internal/serviceinfo/serviceinfo.go b/cli/src/internal/serviceinfo/serviceinfo.go index c132f4344..ca7145cf0 100644 --- a/cli/src/internal/serviceinfo/serviceinfo.go +++ b/cli/src/internal/serviceinfo/serviceinfo.go @@ -398,7 +398,7 @@ func mergeServiceInfo(azureYaml *service.AzureYaml, runningServices []*registry. } // Convert map to slice - var result []*ServiceInfo + result := make([]*ServiceInfo, 0, len(serviceMap)) for _, svc := range serviceMap { result = append(result, svc) } diff --git a/cli/src/internal/testing/config_writer.go b/cli/src/internal/testing/config_writer.go index ceabf5e89..ad23e6553 100644 --- a/cli/src/internal/testing/config_writer.go +++ b/cli/src/internal/testing/config_writer.go @@ -11,6 +11,8 @@ import ( "gopkg.in/yaml.v3" ) +const configTest = "test" + // GenerateTestConfigYAML generates a YAML snippet for discovered test configurations. // Only includes services that were auto-detected (had no config in azure.yaml). // Returns an empty string if no auto-detected services are found. @@ -167,7 +169,7 @@ func addTestConfigToServices(root *yaml.Node, autoDetected map[string]string) er // Check if test config already exists hasTestConfig := false for j := 0; j < len(serviceNode.Content)-1; j += 2 { - if serviceNode.Content[j].Value == "test" { + if serviceNode.Content[j].Value == configTest { hasTestConfig = true break } @@ -181,7 +183,7 @@ func addTestConfigToServices(root *yaml.Node, autoDetected map[string]string) er testKeyNode := &yaml.Node{ Kind: yaml.ScalarNode, Tag: "!!str", - Value: "test", + Value: configTest, } testValueNode := &yaml.Node{ diff --git a/cli/src/internal/testing/coverage.go b/cli/src/internal/testing/coverage.go index d82f7759b..37cc6e0b9 100644 --- a/cli/src/internal/testing/coverage.go +++ b/cli/src/internal/testing/coverage.go @@ -14,6 +14,8 @@ import ( "github.com/jongio/azd-core/security" ) +const formatJSON = "json" + // CoverageAggregator collects and merges coverage data from multiple services type CoverageAggregator struct { serviceCoverage map[string]*CoverageData @@ -139,13 +141,13 @@ func (a *CoverageAggregator) GenerateReport(format string) error { if a.outputDir != "" { // Ensure output directory exists - if err := os.MkdirAll(a.outputDir, 0o755); err != nil { + if err := os.MkdirAll(a.outputDir, 0o750); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } } switch strings.ToLower(format) { - case "json": + case formatJSON: return a.generateJSONReport(aggregate) case "cobertura", "xml": return a.generateCoberturaReport(aggregate) @@ -158,7 +160,7 @@ func (a *CoverageAggregator) GenerateReport(format string) error { // GenerateAllReports generates all coverage report formats func (a *CoverageAggregator) GenerateAllReports() error { - formats := []string{"json", "cobertura", "html"} + formats := []string{formatJSON, "cobertura", "html"} for _, format := range formats { if err := a.GenerateReport(format); err != nil { return err @@ -263,7 +265,7 @@ func (a *CoverageAggregator) generateJSONReport(aggregate *AggregateCoverage) er return fmt.Errorf("failed to marshal coverage data: %w", err) } - if err := os.WriteFile(outputPath, data, 0o644); err != nil { + if err := os.WriteFile(outputPath, data, 0o600); err != nil { return fmt.Errorf("failed to write JSON report: %w", err) } @@ -381,7 +383,7 @@ func (a *CoverageAggregator) generateCoberturaReport(aggregate *AggregateCoverag // Add XML header xmlData := append([]byte(xml.Header), data...) - if err := os.WriteFile(outputPath, xmlData, 0o644); err != nil { + if err := os.WriteFile(outputPath, xmlData, 0o600); err != nil { return fmt.Errorf("failed to write Cobertura report: %w", err) } @@ -560,7 +562,7 @@ func (a *CoverageAggregator) generateHTMLIndex(aggregate *AggregateCoverage) err ` - if err := os.WriteFile(outputPath, []byte(htmlContent), 0o644); err != nil { + if err := os.WriteFile(outputPath, []byte(htmlContent), 0o600); err != nil { return fmt.Errorf("failed to write HTML report: %w", err) } @@ -640,7 +642,7 @@ func (a *CoverageAggregator) generateServiceHTML(service string, coverage *Cover ` - if err := os.WriteFile(outputPath, []byte(htmlContent), 0o644); err != nil { + if err := os.WriteFile(outputPath, []byte(htmlContent), 0o600); err != nil { return fmt.Errorf("failed to write service HTML: %w", err) } @@ -652,7 +654,7 @@ func (a *CoverageAggregator) generateFileHTML(file *FileCoverage) error { outputPath := filepath.Join(a.outputDir, fmt.Sprintf("file-%s.html", sanitizeFilename(file.Path))) // Try to read source file - sourceContent := "" + var sourceContent string sourcePath := file.Path if a.sourceRoot != "" { sourcePath = filepath.Join(a.sourceRoot, file.Path) @@ -746,7 +748,7 @@ func (a *CoverageAggregator) generateFileHTML(file *FileCoverage) error { ` - if err := os.WriteFile(outputPath, []byte(htmlContent), 0o644); err != nil { + if err := os.WriteFile(outputPath, []byte(htmlContent), 0o600); err != nil { return fmt.Errorf("failed to write file HTML: %w", err) } diff --git a/cli/src/internal/testing/detection.go b/cli/src/internal/testing/detection.go index 7d95eb01b..434da220d 100644 --- a/cli/src/internal/testing/detection.go +++ b/cli/src/internal/testing/detection.go @@ -10,6 +10,24 @@ import ( "github.com/jongio/azd-core/security" ) +const ( + langJavaScript = "javascript" + langPython = "python" + langGolang = "golang" + langCSharp = "csharp" + langFSharp = "fsharp" + testTypeUnit = "unit" + testTypeIntegration = "integration" + testTypeE2E = "e2e" + langTypeScript = "typescript" + dirVendor = "vendor" + dirBin = "bin" + dirGit = ".git" + dirPycache = "__pycache__" + dirNodeModules = "node_modules" + dirObj = "obj" +) + // TestTypeDetector detects available test types in a service directory. type TestTypeDetector struct { dir string @@ -58,17 +76,17 @@ func (d *TestTypeDetector) Detect() *DetectedTestTypes { func (d *TestTypeDetector) detectByDirectories(result *DetectedTestTypes) { // Common directory names for each test type unitDirs := []string{ - "unit", "tests/unit", "test/unit", "__tests__/unit", - "src/__tests__/unit", "spec/unit", "Unit", "UnitTests", + testTypeUnit, "tests/" + testTypeUnit, "test/" + testTypeUnit, "__tests__/" + testTypeUnit, + "src/__tests__/" + testTypeUnit, "spec/" + testTypeUnit, "Unit", "UnitTests", } integrationDirs := []string{ - "integration", "tests/integration", "test/integration", - "__tests__/integration", "spec/integration", + testTypeIntegration, "tests/" + testTypeIntegration, "test/" + testTypeIntegration, + "__tests__/" + testTypeIntegration, "spec/" + testTypeIntegration, "Integration", "IntegrationTests", } e2eDirs := []string{ - "e2e", "tests/e2e", "test/e2e", "__tests__/e2e", - "spec/e2e", "E2E", "EndToEnd", "end-to-end", + testTypeE2E, "tests/" + testTypeE2E, "test/" + testTypeE2E, "__tests__/" + testTypeE2E, + "spec/" + testTypeE2E, "E2E", "EndToEnd", "end-to-end", "cypress", "playwright", } @@ -106,15 +124,15 @@ func (d *TestTypeDetector) detectByFilePatterns(result *DetectedTestTypes) { _ = filepath.WalkDir(d.dir, func(path string, entry os.DirEntry, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } // Skip directories if entry.IsDir() { // Skip node_modules, vendor, etc. name := entry.Name() - if name == "node_modules" || name == "vendor" || name == ".git" || - name == "bin" || name == "obj" || name == "__pycache__" { + if name == dirNodeModules || name == dirVendor || name == dirGit || + name == dirBin || name == dirObj || name == dirPycache { return filepath.SkipDir } return nil @@ -161,7 +179,7 @@ type filePatterns struct { // getFilePatterns returns file patterns for the language. func (d *TestTypeDetector) getFilePatterns() filePatterns { switch d.language { - case "js", "javascript", "typescript", "ts": + case "js", langJavaScript, langTypeScript, "ts": return filePatterns{ Unit: []string{ `\.unit\.test\.(ts|tsx|js|jsx)$`, @@ -185,7 +203,7 @@ func (d *TestTypeDetector) getFilePatterns() filePatterns { }, } - case "python", "py": + case langPython, "py": return filePatterns{ Unit: []string{ `test_unit.*\.py$`, @@ -207,7 +225,7 @@ func (d *TestTypeDetector) getFilePatterns() filePatterns { }, } - case "go", "golang": + case "go", langGolang: return filePatterns{ Unit: []string{ `.*_unit_test\.go$`, @@ -223,7 +241,7 @@ func (d *TestTypeDetector) getFilePatterns() filePatterns { }, } - case "csharp", "dotnet", "fsharp", "cs", "fs": + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": return filePatterns{ Unit: []string{ `.*unittests?\.cs$`, @@ -253,14 +271,14 @@ func (d *TestTypeDetector) detectByMarkers(result *DetectedTestTypes) { _ = filepath.WalkDir(d.dir, func(path string, entry os.DirEntry, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } // Skip directories if entry.IsDir() { name := entry.Name() - if name == "node_modules" || name == "vendor" || name == ".git" || - name == "bin" || name == "obj" || name == "__pycache__" { + if name == dirNodeModules || name == dirVendor || name == dirGit || + name == dirBin || name == dirObj || name == dirPycache { return filepath.SkipDir } return nil @@ -273,14 +291,14 @@ func (d *TestTypeDetector) detectByMarkers(result *DetectedTestTypes) { // Validate path before reading (security G304 fix) if err := security.ValidatePath(path); err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } // Read file content // #nosec G304 -- Path validated by security.ValidatePath above content, err := os.ReadFile(path) if err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } contentStr := string(content) @@ -326,7 +344,7 @@ type markerPatterns struct { // getMarkers returns test markers for the language. func (d *TestTypeDetector) getMarkers() markerPatterns { switch d.language { - case "python", "py": + case langPython, "py": return markerPatterns{ Unit: []string{ "@pytest.mark.unit", @@ -344,7 +362,7 @@ func (d *TestTypeDetector) getMarkers() markerPatterns { }, } - case "csharp", "dotnet", "fsharp", "cs", "fs": + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": return markerPatterns{ Unit: []string{ `[Trait("Category", "Unit")]`, @@ -364,7 +382,7 @@ func (d *TestTypeDetector) getMarkers() markerPatterns { }, } - case "go", "golang": + case "go", langGolang: return markerPatterns{ Unit: []string{ "// +build unit", @@ -383,7 +401,7 @@ func (d *TestTypeDetector) getMarkers() markerPatterns { }, } - case "js", "javascript", "typescript", "ts": + case "js", langJavaScript, langTypeScript, "ts": return markerPatterns{ Unit: []string{ "describe.unit", @@ -415,19 +433,19 @@ func (d *TestTypeDetector) isTestFile(filename string) bool { lowFilename := strings.ToLower(filename) switch d.language { - case "js", "javascript", "typescript", "ts": + case "js", langJavaScript, langTypeScript, "ts": return strings.Contains(lowFilename, ".test.") || strings.Contains(lowFilename, ".spec.") || strings.Contains(lowFilename, "_test.") - case "python", "py": + case langPython, "py": return strings.HasPrefix(lowFilename, "test_") || strings.HasSuffix(lowFilename, "_test.py") - case "go", "golang": + case "go", langGolang: return strings.HasSuffix(lowFilename, "_test.go") - case "csharp", "dotnet", "fsharp", "cs", "fs": + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": return strings.Contains(lowFilename, "test") && (strings.HasSuffix(lowFilename, ".cs") || strings.HasSuffix(lowFilename, ".fs")) @@ -442,13 +460,13 @@ func (d *TestTypeDetector) GetAvailableTestTypes() []string { types := make([]string, 0, 3) if detected.HasUnit { - types = append(types, "unit") + types = append(types, testTypeUnit) } if detected.HasIntegration { - types = append(types, "integration") + types = append(types, testTypeIntegration) } if detected.HasE2E { - types = append(types, "e2e") + types = append(types, testTypeE2E) } // If no specific types detected, return "all" @@ -483,19 +501,19 @@ func SuggestTestTypeConfig(dir, language string) *ServiceTestConfig { if detected.HasUnit && len(detected.UnitPaths) > 0 { config.Unit = &TestTypeConfig{ - Pattern: getPatternForType("unit", language), + Pattern: getPatternForType(testTypeUnit, language), } } if detected.HasIntegration && len(detected.IntegrationPaths) > 0 { config.Integration = &TestTypeConfig{ - Pattern: getPatternForType("integration", language), + Pattern: getPatternForType(testTypeIntegration, language), } } if detected.HasE2E && len(detected.E2EPaths) > 0 { config.E2E = &TestTypeConfig{ - Pattern: getPatternForType("e2e", language), + Pattern: getPatternForType(testTypeE2E, language), } } @@ -507,37 +525,37 @@ func getPatternForType(testType, language string) string { lang := strings.ToLower(language) switch testType { - case "unit": + case testTypeUnit: switch lang { - case "go", "golang": + case "go", langGolang: return "^TestUnit" - case "python", "py": + case langPython, "py": return "test_unit" - case "js", "javascript", "typescript", "ts": - return "unit" - case "csharp", "dotnet", "fsharp", "cs", "fs": + case "js", langJavaScript, langTypeScript, "ts": + return testTypeUnit + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": return "Category=Unit" } - case "integration": + case testTypeIntegration: switch lang { - case "go", "golang": + case "go", langGolang: return "^TestIntegration" - case "python", "py": + case langPython, "py": return "test_integration" - case "js", "javascript", "typescript", "ts": - return "integration" - case "csharp", "dotnet", "fsharp", "cs", "fs": + case "js", langJavaScript, langTypeScript, "ts": + return testTypeIntegration + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": return "Category=Integration" } - case "e2e": + case testTypeE2E: switch lang { - case "go", "golang": + case "go", langGolang: return "^TestE2E" - case "python", "py": + case langPython, "py": return "test_e2e" - case "js", "javascript", "typescript", "ts": - return "e2e" - case "csharp", "dotnet", "fsharp", "cs", "fs": + case "js", langJavaScript, langTypeScript, "ts": + return testTypeE2E + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": return "Category=E2E" } } diff --git a/cli/src/internal/testing/dotnet_runner.go b/cli/src/internal/testing/dotnet_runner.go index 29b1d0f53..2c4c1e15b 100644 --- a/cli/src/internal/testing/dotnet_runner.go +++ b/cli/src/internal/testing/dotnet_runner.go @@ -13,6 +13,14 @@ import ( "github.com/jongio/azd-app/cli/src/internal/executor" ) +const ( + testFilterAll = "all" + dotnetCommand = "dotnet" + testCategoryUnit = "Category=Unit" + testCategoryIntegration = "Category=Integration" + testCategoryE2E = "Category=E2E" +) + // DotnetTestRunner runs tests for .NET projects. type DotnetTestRunner struct { projectDir string @@ -66,15 +74,15 @@ func (r *DotnetTestRunner) buildTestCommand(testType string, coverage bool) (str if r.config != nil { // Check if explicit command is configured switch testType { - case "unit": + case testTypeUnit: if r.config.Unit != nil && r.config.Unit.Command != "" { return r.parseCommand(r.config.Unit.Command) } - case "integration": + case testTypeIntegration: if r.config.Integration != nil && r.config.Integration.Command != "" { return r.parseCommand(r.config.Integration.Command) } - case "e2e": + case testTypeE2E: if r.config.E2E != nil && r.config.E2E.Command != "" { return r.parseCommand(r.config.E2E.Command) } @@ -89,7 +97,7 @@ func (r *DotnetTestRunner) buildTestCommand(testType string, coverage bool) (str } // Add filter for test type - if testType != "all" { + if testType != testFilterAll { filter := r.getTestFilter(testType) if filter != "" { args = append(args, "--filter", filter) @@ -104,7 +112,7 @@ func (r *DotnetTestRunner) buildTestCommand(testType string, coverage bool) (str // Add logger for better output args = append(args, "--logger", "console;verbosity=normal") - return "dotnet", args + return dotnetCommand, args } // getTestFilter returns the test filter expression for the test type. @@ -112,26 +120,26 @@ func (r *DotnetTestRunner) getTestFilter(testType string) string { var filter string switch testType { - case "unit": + case testTypeUnit: if r.config != nil && r.config.Unit != nil && r.config.Unit.Filter != "" { filter = r.config.Unit.Filter } else { // Default unit test filter - filter = "Category=Unit" + filter = testCategoryUnit } - case "integration": + case testTypeIntegration: if r.config != nil && r.config.Integration != nil && r.config.Integration.Filter != "" { filter = r.config.Integration.Filter } else { // Default integration test filter - filter = "Category=Integration" + filter = testCategoryIntegration } - case "e2e": + case testTypeE2E: if r.config != nil && r.config.E2E != nil && r.config.E2E.Filter != "" { filter = r.config.E2E.Filter } else { // Default E2E test filter - filter = "Category=E2E" + filter = testCategoryE2E } } @@ -175,7 +183,7 @@ func (r *DotnetTestRunner) findTestProjects() []string { func (r *DotnetTestRunner) parseCommand(cmdStr string) (string, []string) { parts := ParseCommandString(cmdStr) if len(parts) == 0 { - return "dotnet", []string{"test"} + return dotnetCommand, []string{"test"} } if len(parts) == 1 { return parts[0], []string{} diff --git a/cli/src/internal/testing/go_runner.go b/cli/src/internal/testing/go_runner.go index cc3528998..f758f0b46 100644 --- a/cli/src/internal/testing/go_runner.go +++ b/cli/src/internal/testing/go_runner.go @@ -66,15 +66,15 @@ func (r *GoTestRunner) buildTestCommand(testType string, coverage bool) (string, if r.config != nil { // Check if explicit command is configured switch testType { - case "unit": + case testTypeUnit: if r.config.Unit != nil && r.config.Unit.Command != "" { return r.parseCommand(r.config.Unit.Command) } - case "integration": + case testTypeIntegration: if r.config.Integration != nil && r.config.Integration.Command != "" { return r.parseCommand(r.config.Integration.Command) } - case "e2e": + case testTypeE2E: if r.config.E2E != nil && r.config.E2E.Command != "" { return r.parseCommand(r.config.E2E.Command) } @@ -82,7 +82,7 @@ func (r *GoTestRunner) buildTestCommand(testType string, coverage bool) (string, } // Add test pattern filter for test type - if testType != "all" { + if testType != testFilterAll { pattern := r.getTestPattern(testType) if pattern != "" { args = append(args, "-run", pattern) @@ -107,21 +107,21 @@ func (r *GoTestRunner) getTestPattern(testType string) string { var pattern string switch testType { - case "unit": + case testTypeUnit: if r.config != nil && r.config.Unit != nil && r.config.Unit.Pattern != "" { pattern = r.config.Unit.Pattern } else { // Default: match tests that don't contain Integration or E2E pattern = "^Test[^IE]|^Test$|^TestUnit" } - case "integration": + case testTypeIntegration: if r.config != nil && r.config.Integration != nil && r.config.Integration.Pattern != "" { pattern = r.config.Integration.Pattern } else { // Default: match tests with Integration in name pattern = "Integration" } - case "e2e": + case testTypeE2E: if r.config != nil && r.config.E2E != nil && r.config.E2E.Pattern != "" { pattern = r.config.E2E.Pattern } else { @@ -251,7 +251,7 @@ func (r *GoTestRunner) HasTests() bool { hasTests := false _ = filepath.Walk(r.projectDir, func(path string, info os.FileInfo, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") { hasTests = true diff --git a/cli/src/internal/testing/node_runner.go b/cli/src/internal/testing/node_runner.go index ca40e99e7..298303951 100644 --- a/cli/src/internal/testing/node_runner.go +++ b/cli/src/internal/testing/node_runner.go @@ -17,6 +17,11 @@ import ( // ansiStripRegex matches ANSI escape sequences for removal. var ansiStripRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) +const ( + pkgMgrNPM = "npm" + pkgMgrPNPM = "pnpm" +) + // stripAnsi removes ANSI escape codes from a string. func stripAnsi(s string) string { return ansiStripRegex.ReplaceAllString(s, "") @@ -34,7 +39,7 @@ func NewNodeTestRunner(projectDir string, config *ServiceTestConfig) *NodeTestRu // Detect package manager packageManager := detector.DetectNodePackageManagerWithBoundary(projectDir, projectDir) if packageManager == "" { - packageManager = "npm" + packageManager = pkgMgrNPM } return &NodeTestRunner{ @@ -86,15 +91,15 @@ func (r *NodeTestRunner) buildTestCommand(testType string, coverage bool) (strin // Check if explicit command is configured switch testType { - case "unit": + case testTypeUnit: if r.config.Unit != nil && r.config.Unit.Command != "" { return r.parseCommand(r.config.Unit.Command) } - case "integration": + case testTypeIntegration: if r.config.Integration != nil && r.config.Integration.Command != "" { return r.parseCommand(r.config.Integration.Command) } - case "e2e": + case testTypeE2E: if r.config.E2E != nil && r.config.E2E.Command != "" { return r.parseCommand(r.config.E2E.Command) } @@ -102,28 +107,28 @@ func (r *NodeTestRunner) buildTestCommand(testType string, coverage bool) (strin // Build default command based on framework switch r.config.Framework { - case "jest": + case frameworkJest: args = []string{"test"} - if testType != "all" { + if testType != testFilterAll { args = append(args, "--", fmt.Sprintf("--testPathPattern=%s", testType)) } if coverage { args = append(args, "--coverage") } - case "vitest": + case frameworkVitest: args = []string{"test", "--run"} - if testType != "all" { + if testType != testFilterAll { args = append(args, fmt.Sprintf("--testNamePattern=%s", testType)) } if coverage { args = append(args, "--coverage") } - case "mocha": + case frameworkMocha: args = []string{"test"} // Mocha typically uses different test files for different types - if testType != "all" { + if testType != testFilterAll { args = append(args, fmt.Sprintf("test/%s/**/*.test.js", testType)) } diff --git a/cli/src/internal/testing/orchestrator.go b/cli/src/internal/testing/orchestrator.go index 1f4169cac..6cccfa5dd 100644 --- a/cli/src/internal/testing/orchestrator.go +++ b/cli/src/internal/testing/orchestrator.go @@ -19,6 +19,17 @@ import ( // DefaultTestTimeout is the default timeout for test execution per service. const DefaultTestTimeout = 10 * time.Minute +const ( + frameworkJest = "jest" + frameworkVitest = "vitest" + frameworkMocha = "mocha" + frameworkPytest = "pytest" + frameworkXUnit = "xunit" + frameworkNUnit = "nunit" + frameworkMSTest = "mstest" + frameworkGoTest = "gotest" +) + // ProgressCallback is a function that gets called during test execution to report progress. type ProgressCallback func(event ProgressEvent) @@ -203,7 +214,7 @@ func (o *TestOrchestrator) DetectTestConfig(service ServiceInfo) (*ServiceTestCo config := &ServiceTestConfig{} switch strings.ToLower(service.Language) { - case "js", "javascript", "typescript", "ts": + case "js", langJavaScript, langTypeScript, "ts": framework, err := detectNodeTestFramework(service.Dir) if err != nil { return nil, fmt.Errorf("failed to detect Node.js test framework: %w", err) @@ -217,7 +228,7 @@ func (o *TestOrchestrator) DetectTestConfig(service ServiceInfo) (*ServiceTestCo } config.Framework = framework - case "csharp", "dotnet", "fsharp", "cs", "fs": + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": framework, err := detectDotnetTestFramework(service.Dir) if err != nil { return nil, fmt.Errorf("failed to detect .NET test framework: %w", err) @@ -557,13 +568,13 @@ func (o *TestOrchestrator) executeServiceTests(service ServiceInfo, testType str // Create appropriate test runner based on language var runner TestRunner switch strings.ToLower(service.Language) { - case "js", "javascript", "typescript", "ts": + case "js", langJavaScript, langTypeScript, "ts": runner = NewNodeTestRunner(service.Dir, config) - case "python", "py": + case langPython, "py": runner = NewPythonTestRunner(service.Dir, config) - case "csharp", "dotnet", "fsharp", "cs", "fs": + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": runner = NewDotnetTestRunner(service.Dir, config) - case "go", "golang": + case "go", langGolang: runner = NewGoTestRunner(service.Dir, config) default: return nil, fmt.Errorf("unsupported language: %s", service.Language) @@ -634,17 +645,17 @@ func (o *TestOrchestrator) GetServicePaths() ([]string, error) { // Helper functions // detectNodeTestFramework detects the Node.js test framework. -func detectNodeTestFramework(dir string) (string, error) { +func detectNodeTestFramework(dir string) (string, error) { //nolint:unparam // return value kept for future use/interface conformance // Check for configuration files configFiles := map[string]string{ - "jest.config.js": "jest", - "jest.config.ts": "jest", - "jest.config.json": "jest", - "vitest.config.js": "vitest", - "vitest.config.ts": "vitest", - ".mocharc.js": "mocha", - ".mocharc.json": "mocha", - ".mocharc.yaml": "mocha", + "jest.config.js": frameworkJest, + "jest.config.ts": frameworkJest, + "jest.config.json": frameworkJest, + "vitest.config.js": frameworkVitest, + "vitest.config.ts": frameworkVitest, + ".mocharc.js": frameworkMocha, + ".mocharc.json": frameworkMocha, + ".mocharc.yaml": frameworkMocha, } for file, framework := range configFiles { @@ -661,37 +672,37 @@ func detectNodeTestFramework(dir string) (string, error) { if err == nil { content := string(data) if strings.Contains(content, `"jest"`) { - return "jest", nil + return frameworkJest, nil } if strings.Contains(content, `"vitest"`) { - return "vitest", nil + return frameworkVitest, nil } if strings.Contains(content, `"mocha"`) { - return "mocha", nil + return frameworkMocha, nil } } } // Default to npm test - return "npm", nil + return pkgMgrNPM, nil } // detectPythonTestFramework detects the Python test framework. -func detectPythonTestFramework(dir string) (string, error) { +func detectPythonTestFramework(dir string) (string, error) { //nolint:unparam // return value kept for future use/interface conformance // Check for pytest configuration pytestFiles := []string{"pytest.ini", "pyproject.toml", "setup.cfg"} for _, file := range pytestFiles { if _, err := os.Stat(filepath.Join(dir, file)); err == nil { - return "pytest", nil + return frameworkPytest, nil } } // Check for tests directory if _, err := os.Stat(filepath.Join(dir, "tests")); err == nil { - return "pytest", nil + return frameworkPytest, nil } - return "pytest", nil // Default to pytest + return frameworkPytest, nil // Default to pytest } // detectDotnetTestFramework detects the .NET test framework. @@ -710,20 +721,20 @@ func detectDotnetTestFramework(dir string) (string, error) { data, err := os.ReadFile(proj.Path) if err == nil { content := string(data) - if strings.Contains(content, "xunit") { - return "xunit", nil + if strings.Contains(content, frameworkXUnit) { + return frameworkXUnit, nil } if strings.Contains(content, "NUnit") { - return "nunit", nil + return frameworkNUnit, nil } if strings.Contains(content, "MSTest") { - return "mstest", nil + return frameworkMSTest, nil } } } } - return "xunit", nil // Default to xUnit + return frameworkXUnit, nil // Default to xUnit } // detectGoTestFramework detects the Go test framework. @@ -751,7 +762,7 @@ func detectGoTestFramework(dir string) (string, error) { if !hasTests { _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // unreadable paths should not block fallback test discovery } if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") { hasTests = true @@ -765,7 +776,7 @@ func detectGoTestFramework(dir string) (string, error) { return "", fmt.Errorf("no test files found in %s", dir) } - return "gotest", nil + return frameworkGoTest, nil } // filterServices filters services by name. @@ -825,7 +836,7 @@ func runCommand(dir, cmd string) error { } // #nosec G204 -- Command parts are validated and from azure.yaml - command := exec.Command(parts[0], parts[1:]...) + command := exec.CommandContext(context.Background(), parts[0], parts[1:]...) command.Dir = dir command.Stdout = os.Stdout command.Stderr = os.Stderr diff --git a/cli/src/internal/testing/python_runner.go b/cli/src/internal/testing/python_runner.go index afb47767b..5fdb329e5 100644 --- a/cli/src/internal/testing/python_runner.go +++ b/cli/src/internal/testing/python_runner.go @@ -13,6 +13,8 @@ import ( "github.com/jongio/azd-app/cli/src/internal/executor" ) +const pkgMgrPoetry = "poetry" + // PythonTestRunner runs tests for Python projects. type PythonTestRunner struct { projectDir string @@ -72,15 +74,15 @@ func (r *PythonTestRunner) buildTestCommand(testType string, coverage bool) (str // Check if explicit command is configured switch testType { - case "unit": + case testTypeUnit: if r.config.Unit != nil && r.config.Unit.Command != "" { return r.parseCommand(r.config.Unit.Command) } - case "integration": + case testTypeIntegration: if r.config.Integration != nil && r.config.Integration.Command != "" { return r.parseCommand(r.config.Integration.Command) } - case "e2e": + case testTypeE2E: if r.config.E2E != nil && r.config.E2E.Command != "" { return r.parseCommand(r.config.E2E.Command) } @@ -88,7 +90,7 @@ func (r *PythonTestRunner) buildTestCommand(testType string, coverage bool) (str // Build default command based on framework and package manager switch r.config.Framework { - case "pytest": + case frameworkPytest: return r.buildPytestCommand(testType, coverage) case "unittest": return r.buildUnittestCommand(testType, coverage) @@ -107,36 +109,36 @@ func (r *PythonTestRunner) buildPytestCommand(testType string, coverage bool) (s switch r.packageManager { case "uv": command = "uv" - args = []string{"run", "pytest"} - case "poetry": - command = "poetry" - args = []string{"run", "pytest"} + args = []string{"run", frameworkPytest} + case pkgMgrPoetry: + command = pkgMgrPoetry + args = []string{"run", frameworkPytest} default: - command = "pytest" + command = frameworkPytest } // Add markers for test type filtering - if testType != "all" && r.config != nil { + if testType != testFilterAll && r.config != nil { // Check if config has markers var markers []string switch testType { - case "unit": + case testTypeUnit: if r.config.Unit != nil && len(r.config.Unit.Markers) > 0 { markers = r.config.Unit.Markers } else { - markers = []string{"unit"} + markers = []string{testTypeUnit} } - case "integration": + case testTypeIntegration: if r.config.Integration != nil && len(r.config.Integration.Markers) > 0 { markers = r.config.Integration.Markers } else { - markers = []string{"integration"} + markers = []string{testTypeIntegration} } - case "e2e": + case testTypeE2E: if r.config.E2E != nil && len(r.config.E2E.Markers) > 0 { markers = r.config.E2E.Markers } else { - markers = []string{"e2e"} + markers = []string{testTypeE2E} } } @@ -144,7 +146,7 @@ func (r *PythonTestRunner) buildPytestCommand(testType string, coverage bool) (s for _, marker := range markers { args = append(args, "-m", marker) } - } else if testType != "all" { + } else if testType != testFilterAll { // Default markers when config is nil args = append(args, "-m", testType) } @@ -174,8 +176,8 @@ func (r *PythonTestRunner) buildUnittestCommand(testType string, coverage bool) case "uv": command = "uv" args = []string{"run", "coverage", "run", "-m", "unittest", "discover"} - case "poetry": - command = "poetry" + case pkgMgrPoetry: + command = pkgMgrPoetry args = []string{"run", "coverage", "run", "-m", "unittest", "discover"} default: command = "coverage" @@ -187,17 +189,17 @@ func (r *PythonTestRunner) buildUnittestCommand(testType string, coverage bool) case "uv": command = "uv" args = []string{"run", "python", "-m", "unittest", "discover"} - case "poetry": - command = "poetry" + case pkgMgrPoetry: + command = pkgMgrPoetry args = []string{"run", "python", "-m", "unittest", "discover"} default: - command = "python" + command = langPython args = []string{"-m", "unittest", "discover"} } } // Add test path - either type-specific or general tests directory - if testType != "all" { + if testType != testFilterAll { // Try to filter by directory testDir := fmt.Sprintf("tests/%s", testType) if _, err := os.Stat(filepath.Join(r.projectDir, testDir)); err == nil { @@ -218,7 +220,7 @@ func (r *PythonTestRunner) buildUnittestCommand(testType string, coverage bool) func (r *PythonTestRunner) parseCommand(cmdStr string) (string, []string) { parts := ParseCommandString(cmdStr) if len(parts) == 0 { - return "pytest", []string{} + return frameworkPytest, []string{} } if len(parts) == 1 { return parts[0], []string{} @@ -236,7 +238,7 @@ func (r *PythonTestRunner) parseTestOutput(output string, result *TestResult) { // Parse pytest summary line // Example: "5 passed in 1.23s" // Example: "4 passed, 1 failed in 1.23s" - if r.config.Framework == "pytest" || r.config.Framework == "" { + if r.config.Framework == frameworkPytest || r.config.Framework == "" { r.parsePytestSummary(line, result) } @@ -351,7 +353,7 @@ func detectPythonPackageManager(dir string) string { // Check for poetry if _, err := os.Stat(filepath.Join(dir, "poetry.lock")); err == nil { - return "poetry" + return pkgMgrPoetry } // Check for pyproject.toml with uv or poetry @@ -363,7 +365,7 @@ func detectPythonPackageManager(dir string) string { return "uv" } if strings.Contains(content, "[tool.poetry]") { - return "poetry" + return pkgMgrPoetry } } diff --git a/cli/src/internal/testing/reporter.go b/cli/src/internal/testing/reporter.go index 083cc7248..2d345fa98 100644 --- a/cli/src/internal/testing/reporter.go +++ b/cli/src/internal/testing/reporter.go @@ -30,13 +30,13 @@ func NewReportGenerator(format, outputDir string) *ReportGenerator { // GenerateTestReport generates a test report based on the configured format. func (g *ReportGenerator) GenerateTestReport(results *AggregateResult) error { if g.outputDir != "" { - if err := os.MkdirAll(g.outputDir, 0o755); err != nil { + if err := os.MkdirAll(g.outputDir, 0o750); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } } switch strings.ToLower(g.format) { - case "json": + case formatJSON: return g.generateJSONReport(results) case "junit": return g.generateJUnitReport(results) @@ -56,7 +56,7 @@ func (g *ReportGenerator) generateJSONReport(results *AggregateResult) error { return fmt.Errorf("failed to marshal test results: %w", err) } - if err := os.WriteFile(outputPath, data, 0o644); err != nil { + if err := os.WriteFile(outputPath, data, 0o600); err != nil { return fmt.Errorf("failed to write JSON report: %w", err) } @@ -201,7 +201,7 @@ func (g *ReportGenerator) generateJUnitReport(results *AggregateResult) error { // Add XML header xmlData := append([]byte(xml.Header), data...) - if err := os.WriteFile(outputPath, xmlData, 0o644); err != nil { + if err := os.WriteFile(outputPath, xmlData, 0o600); err != nil { return fmt.Errorf("failed to write JUnit report: %w", err) } @@ -233,7 +233,7 @@ func (g *ReportGenerator) generateGitHubReport(results *AggregateResult) error { summaryFile := os.Getenv("GITHUB_STEP_SUMMARY") if summaryFile != "" { summary := g.generateGitHubSummary(results) - if err := os.WriteFile(summaryFile, []byte(summary), 0644); err != nil { + if err := os.WriteFile(summaryFile, []byte(summary), 0o600); err != nil { // Don't fail if we can't write summary log := logging.NewLogger("test") log.Warn("failed to write GitHub summary", "error", err.Error()) @@ -257,7 +257,7 @@ func (g *ReportGenerator) generateGitHubReport(results *AggregateResult) error { fmt.Sprintf("coverage_percent=%.1f", results.Coverage.Aggregate.Lines.Percent)) } - f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err == nil { defer func() { _ = f.Close() }() for _, output := range outputs { diff --git a/cli/src/internal/testing/testutil/bindloopback.go b/cli/src/internal/testing/testutil/bindloopback.go index 2792d36bf..67128060d 100644 --- a/cli/src/internal/testing/testutil/bindloopback.go +++ b/cli/src/internal/testing/testutil/bindloopback.go @@ -1,6 +1,7 @@ package testutil import ( + "context" "fmt" "net" ) @@ -10,11 +11,17 @@ import ( // the chosen port. Caller should Close() the listener when done. func ListenLoopback(port int) (net.Listener, int, error) { addr := fmt.Sprintf("127.0.0.1:%d", port) - listener, err := net.Listen("tcp", addr) + lc := net.ListenConfig{} + listener, err := lc.Listen(context.Background(), "tcp", addr) if err != nil { return nil, 0, err } // Extract actual port (in case 0 was passed) - actual := listener.Addr().(*net.TCPAddr).Port - return listener, actual, nil + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + _ = listener.Close() + return nil, 0, fmt.Errorf("listener addr has unexpected type %T", listener.Addr()) + } + + return listener, tcpAddr.Port, nil } diff --git a/cli/src/internal/testing/testutil/bindloopback_test.go b/cli/src/internal/testing/testutil/bindloopback_test.go index aef3297fe..62a17c470 100644 --- a/cli/src/internal/testing/testutil/bindloopback_test.go +++ b/cli/src/internal/testing/testutil/bindloopback_test.go @@ -1,6 +1,7 @@ package testutil import ( + "context" "net" "testing" "time" @@ -31,7 +32,8 @@ func TestListenLoopback(t *testing.T) { deadline := time.Now().Add(500 * time.Millisecond) var conn net.Conn for time.Now().Before(deadline) { - conn, err = net.Dial("tcp", listener.Addr().String()) + dialer := net.Dialer{} + conn, err = dialer.DialContext(context.Background(), "tcp", listener.Addr().String()) if err == nil { _ = conn.Close() return diff --git a/cli/src/internal/testing/validation.go b/cli/src/internal/testing/validation.go index 01e0393ac..497c06368 100644 --- a/cli/src/internal/testing/validation.go +++ b/cli/src/internal/testing/validation.go @@ -47,13 +47,13 @@ func ValidateService(service ServiceInfo) ServiceValidation { // Validate based on language switch strings.ToLower(service.Language) { - case "js", "javascript", "typescript", "ts": + case "js", langJavaScript, langTypeScript, "ts": return validateNodeService(service, validation) - case "python", "py": + case langPython, "py": return validatePythonService(service, validation) - case "go", "golang": + case "go", langGolang: return validateGoService(service, validation) - case "csharp", "dotnet", "fsharp", "cs", "fs": + case langCSharp, dotnetCommand, langFSharp, "cs", "fs": return validateDotnetService(service, validation) default: validation.SkipReason = "Unsupported language: " + service.Language @@ -65,11 +65,11 @@ func ValidateService(service ServiceInfo) ServiceValidation { func validateNodeService(service ServiceInfo, validation ServiceValidation) ServiceValidation { // Check for test framework config files frameworkConfigs := map[string]string{ - "jest.config.js": "jest", - "jest.config.ts": "jest", - "jest.config.json": "jest", - "vitest.config.js": "vitest", - "vitest.config.ts": "vitest", + "jest.config.js": frameworkJest, + "jest.config.ts": frameworkJest, + "jest.config.json": frameworkJest, + "vitest.config.js": frameworkVitest, + "vitest.config.ts": frameworkVitest, ".mocharc.js": "mocha", ".mocharc.json": "mocha", ".mocharc.yaml": "mocha", @@ -96,10 +96,10 @@ func validateNodeService(service ServiceInfo, validation ServiceValidation) Serv // Detect framework from dependencies if not already detected if validation.Framework == "" { - if strings.Contains(content, `"vitest"`) { - validation.Framework = "vitest" - } else if strings.Contains(content, `"jest"`) { - validation.Framework = "jest" + if strings.Contains(content, "\""+frameworkVitest+"\"") { + validation.Framework = frameworkVitest + } else if strings.Contains(content, "\""+frameworkJest+"\"") { + validation.Framework = frameworkJest } else if strings.Contains(content, `"mocha"`) { validation.Framework = "mocha" } @@ -125,7 +125,7 @@ func validateNodeService(service ServiceInfo, validation ServiceValidation) Serv if testFileCount > 0 || hasTestScript { validation.CanTest = true if validation.Framework == "" { - validation.Framework = "npm" + validation.Framework = pkgMgrNPM } } else { validation.SkipReason = "No test script in package.json and no test files found" @@ -148,13 +148,13 @@ func validatePythonService(service ServiceInfo, validation ServiceValidation) Se if data, err := os.ReadFile(configPath); err == nil { if strings.Contains(string(data), "[tool.pytest") { hasPytestConfig = true - validation.Framework = "pytest" + validation.Framework = frameworkPytest break } } } else { hasPytestConfig = true - validation.Framework = "pytest" + validation.Framework = frameworkPytest break } } @@ -186,7 +186,7 @@ func validatePythonService(service ServiceInfo, validation ServiceValidation) Se if testFileCount > 0 || hasTestDir || hasPytestConfig { validation.CanTest = true if validation.Framework == "" { - validation.Framework = "pytest" + validation.Framework = frameworkPytest } } else { validation.SkipReason = "No pytest configuration and no test files found" @@ -208,7 +208,7 @@ func validateGoService(service ServiceInfo, validation ServiceValidation) Servic testFileCount := 0 _ = filepath.WalkDir(service.Dir, func(path string, d os.DirEntry, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } // Skip vendor directory if d.IsDir() && d.Name() == "vendor" { @@ -221,7 +221,7 @@ func validateGoService(service ServiceInfo, validation ServiceValidation) Servic }) validation.TestFiles = testFileCount - validation.Framework = "gotest" + validation.Framework = frameworkGoTest if testFileCount > 0 { validation.CanTest = true @@ -240,7 +240,7 @@ func validateDotnetService(service ServiceInfo, validation ServiceValidation) Se _ = filepath.WalkDir(service.Dir, func(path string, d os.DirEntry, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } // Skip bin and obj directories if d.IsDir() && (d.Name() == "bin" || d.Name() == "obj") { @@ -257,8 +257,8 @@ func validateDotnetService(service ServiceInfo, validation ServiceValidation) Se // #nosec G304 -- Path is from filepath.WalkDir of validated service.Dir if data, err := os.ReadFile(path); err == nil { content := string(data) - if strings.Contains(content, "xunit") { - framework = "xunit" + if strings.Contains(content, frameworkXUnit) { + framework = frameworkXUnit } else if strings.Contains(content, "NUnit") { framework = "nunit" } else if strings.Contains(content, "MSTest") { @@ -275,7 +275,7 @@ func validateDotnetService(service ServiceInfo, validation ServiceValidation) Se if framework != "" { validation.Framework = framework } else if testProjectCount > 0 { - validation.Framework = "dotnet" + validation.Framework = dotnetCommand } if testProjectCount > 0 { @@ -310,7 +310,7 @@ func countTestFiles(dir string, patterns []string) int { basePattern := strings.Replace(pattern, "**/", "", 1) _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { - return nil + return nil //nolint:nilerr // missing file is expected in detection logic } // Skip node_modules, vendor, and other common ignore dirs if d.IsDir() { diff --git a/cli/src/internal/testing/watcher.go b/cli/src/internal/testing/watcher.go index 782fe37aa..bbbf33e2f 100644 --- a/cli/src/internal/testing/watcher.go +++ b/cli/src/internal/testing/watcher.go @@ -47,9 +47,9 @@ func WithDebounceDelay(delay time.Duration) WatcherOption { } // WithClearConsole enables clearing the console between runs -func WithClearConsole(clear bool) WatcherOption { +func WithClearConsole(clearConsole bool) WatcherOption { return func(w *FileWatcher) { - w.clearConsole = clear + w.clearConsole = clearConsole } } @@ -189,7 +189,7 @@ func (w *FileWatcher) WatchWithServiceFilter(ctx context.Context, callback Watch } // scheduleDebouncedCallback schedules a callback after debounce delay -func (w *FileWatcher) scheduleDebouncedCallback(ctx context.Context, callback WatchCallback) { +func (w *FileWatcher) scheduleDebouncedCallback(_ context.Context, callback WatchCallback) { // Cancel existing timer if any if w.debounceTimer != nil { w.debounceTimer.Stop() @@ -334,7 +334,7 @@ func (w *FileWatcher) scanFiles() error { for _, path := range w.paths { err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { if err != nil { - return nil // Skip files we can't access + return nil //nolint:nilerr // missing file is expected in detection logic } // Skip directories and ignored patterns @@ -368,7 +368,7 @@ func (w *FileWatcher) checkForChanges() ([]string, error) { for _, path := range w.paths { err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { if err != nil { - return nil // Skip files we can't access + return nil //nolint:nilerr // missing file is expected in detection logic } // Skip directories and ignored patterns diff --git a/cli/tests/projects/package-managers/python/test-poetry-project/README.md b/cli/tests/projects/package-managers/python/test-poetry-project/README.md index 19eab0f11..3ab142194 100644 --- a/cli/tests/projects/package-managers/python/test-poetry-project/README.md +++ b/cli/tests/projects/package-managers/python/test-poetry-project/README.md @@ -92,7 +92,7 @@ Without this test, we wouldn't validate: - Virtual environment automatic management ### Real-World Scenario -Modern Python projects prefer Poetry for its superior dependency resolution, lockfile guarantees, and simplified dependency management. This test ensures Poetry-based projects work seamlessly. +Modern Python projects prefer Poetry for its superior dependency resolution, lockfile guarantees, and simplified dependency management. This test ensures Poetry-based projects are supported. ## Test Matrix diff --git a/docs/research/cli-output-styling.md b/docs/research/cli-output-styling.md index 2afa0d6ef..7163ffe01 100644 --- a/docs/research/cli-output-styling.md +++ b/docs/research/cli-output-styling.md @@ -471,7 +471,7 @@ Packages: +5 -1 azd-app already has excellent foundations with `fatih/color` and `charmbracelet/lipgloss`. To achieve pnpm/Astro-level polish: -1. **Leverage lipgloss** for badges, boxes, and layouts +1. **Use lipgloss** for badges, boxes, and layouts 2. **Add go-pretty** for better tables 3. **Add spinner library** for long operations 4. **Standardize color palette** across all output diff --git a/docs/specs/azd-app/tasks.md b/docs/specs/azd-app/tasks.md index 601222217..529971848 100644 --- a/docs/specs/azd-app/tasks.md +++ b/docs/specs/azd-app/tasks.md @@ -23,7 +23,7 @@ - Acceptance criteria: Unit tests pass; any integration tests that require network access are documented and skipped on Windows or isolated. ### 7. Add azd-core dependency -- Description: Leverage the existing Go workspace (`go.work`) in `c:\code` so local development uses `azd-core` without `go.mod replace`. For CI, add a `require` and pin to a tagged `azd-core` version (workspace not used in CI). +- Description: Use the existing Go workspace (`go.work`) in `c:\code` so local development uses `azd-core` without `go.mod replace`. For CI, add a `require` and pin to a tagged `azd-core` version (workspace not used in CI). - Acceptance criteria: Local builds succeed via workspace; CI configured to use a tagged version without replace. ### 9. Deferred: other core methods diff --git a/docs/specs/azd-framework-enhancements/spec.md b/docs/specs/azd-framework-enhancements/spec.md index f6f59ff0d..050da65fa 100644 --- a/docs/specs/azd-framework-enhancements/spec.md +++ b/docs/specs/azd-framework-enhancements/spec.md @@ -38,7 +38,7 @@ The azure-dev project has introduced several significant enhancements to the ext 1. **Enhance Observability**: Add distributed tracing to azd-exec and azd-app 2. **Improve Error Handling**: Adopt structured error model across all extensions 3. **Better Developer Experience**: Export debugger support, interactive mode -4. **Leverage Event Context**: Utilize ServiceContext in azd-app lifecycle handlers +4. **Use Event Context**: Utilize ServiceContext in azd-app lifecycle handlers 5. **Improve Reliability**: Auto cleanup, duplicate prevention 6. **Enable Extensibility**: Support additional properties for extension config @@ -349,7 +349,7 @@ type ServiceContext struct { #### Changes Required **azd-app/cli/src/cmd/app/commands/listen.go** -Update all service event handlers to leverage ServiceContext: +Update all service event handlers to use ServiceContext: ```go .WithServiceEventHandler("postbuild", func(ctx context.Context, args *azdext.ServiceEventArgs) error { // Example: Analyze build artifacts @@ -542,7 +542,7 @@ Enable azd-app dashboard and other interactive features to work properly when la azd-app dashboard runs as background process, may have issues with interactive azd commands. #### Enhancement -Leverage the new interactive extension support for TUI features. +Use the new interactive extension support for TUI features. #### Changes Required @@ -757,7 +757,7 @@ func NewStartCommand() *cobra.Command { ### Alternative 1: Custom Tracing Solution **Pros:** Full control, no azd dependency **Cons:** Reinventing the wheel, no correlation with azd traces -**Decision:** Rejected - leverage existing framework +**Decision:** Rejected - use existing framework ### Alternative 2: Separate Config File (app-config.yaml) **Pros:** Clear separation, easier validation diff --git a/docs/specs/azure-logs-web-docs/spec.md b/docs/specs/azure-logs-web-docs/spec.md index 3f199108b..3b4145907 100644 --- a/docs/specs/azure-logs-web-docs/spec.md +++ b/docs/specs/azure-logs-web-docs/spec.md @@ -323,7 +323,7 @@ web/src/pages/reference/ **Key Messages**: - "Monitor production without leaving your terminal" -- "Bridge local development and Azure cloud seamlessly" +- "Bridge local development and Azure cloud with a single command" - "Real-time insights from Container Apps, App Service, and Functions" - "No context switching between Azure Portal and local environment" diff --git a/web/src/pages/mcp/index.astro b/web/src/pages/mcp/index.astro index 68507ec70..85b60a5d2 100644 --- a/web/src/pages/mcp/index.astro +++ b/web/src/pages/mcp/index.astro @@ -132,7 +132,7 @@ const featuredPrompts = [

MCP Server

The Model Context Protocol (MCP) server connects AI assistants like GitHub Copilot - to your running services, enabling intelligent debugging and management. + to your running services for debugging and management.

diff --git a/web/src/pages/quick-start.astro b/web/src/pages/quick-start.astro index 6f1c2aee0..b9fac0012 100644 --- a/web/src/pages/quick-start.astro +++ b/web/src/pages/quick-start.astro @@ -31,7 +31,7 @@ const nextSteps = [ { icon: "🎯", title: "Guided Tour", - description: "Take a comprehensive tour of all azd-app features", + description: "Walk through all azd-app features step by step", href: import.meta.env.BASE_URL + "tour/", isPrimary: true, }, diff --git a/web/src/pages/reference/azure-yaml.astro b/web/src/pages/reference/azure-yaml.astro index 59c8e6879..34636c6c3 100644 --- a/web/src/pages/reference/azure-yaml.astro +++ b/web/src/pages/reference/azure-yaml.astro @@ -856,7 +856,7 @@ const resourceTypesExample = `resources: + description="Complete azure.yaml configuration reference. Every property, option, and pattern with examples.">
diff --git a/web/src/pages/reference/changelog/index.astro b/web/src/pages/reference/changelog/index.astro index a6c85e254..46602e5f4 100644 --- a/web/src/pages/reference/changelog/index.astro +++ b/web/src/pages/reference/changelog/index.astro @@ -32,12 +32,111 @@ import Layout from '../../../components/Layout.astro';
+ +
+
+

+ v0.12.8 +

+ + +
+
    + +
  • + 🐛 + fix: make azd app run work without azd environment (#151) (9e36bbf4) +
  • +
  • + 📝 + perf: parallelize mage preflight with DAG-based execution (27min → 3:24) (#149) (d0b61401) +
  • +
  • + 🔧 + chore: update registry for v0.12.7 (1de00410) +
  • +
+
+ +
+
+

+ v0.12.7 +

+ + +
+
    + +
  • + 🐛 + fix: add GitHub Packages auth to preflight job (24e354fc) +
  • +
  • + 🐛 + fix: add packages:read permission to deploy-website job in release workflow (22ae660d) +
  • +
  • + + feat: limit deps command to azure.yaml services only (#147) (ad5a6218) +
  • +
  • + 🐛 + fix: use correct repo creation dates for LinkedIn publish date (85897788) +
  • +
  • + 🐛 + fix: add LinkedIn publish date via azd-web-core v2.3.0 (86bd8f12) +
  • +
  • + 🐛 + fix: add LinkedIn author meta tag via azd-web-core v2.2.0 (007bba01) +
  • +
  • + + feat: add social media OG images and consistent titles (e59857f2) +
  • +
  • + 📝 + feat(web): redesign with shared azd-web-core v2 (#146) (026a03f7) +
  • +
  • + 🔧 + chore: remove orphaned Header, Footer, ThemeToggle components (0bbd2b3d) +
  • +
  • + 🔧 + chore: update registry for v0.12.6 (95204d60) +
  • +
+
+ +

v0.12.6

- Latest +
    diff --git a/web/src/pages/reference/whats-new/index.astro b/web/src/pages/reference/whats-new/index.astro index 7ad4e8455..af12d64c5 100644 --- a/web/src/pages/reference/whats-new/index.astro +++ b/web/src/pages/reference/whats-new/index.astro @@ -49,16 +49,20 @@ import Layout from '../../../components/Layout.astro';

    - v0.12.6 + v0.13.0

    - + Latest
      +
    • + + feat: adopt azdext SDK helpers — full extension framework migration (#145) (969c6956) +
    • 🔧 - chore: update registry for v0.12.5 (13ccec3f) + chore: update registry for v0.12.8 (849b37b1)
    @@ -66,24 +70,24 @@ import Layout from '../../../components/Layout.astro';

    - v0.12.5 + v0.12.8

    - +
    • - - feat: add metadata capability + fix flaky cache test (#142) (6fd3ef3c) + 🐛 + fix: make azd app run work without azd environment (#151) (9e36bbf4)
    • - 🐛 - fix: replace subprocess MCP log/error tools with in-process collect() calls (#141) (01cdf511) + 📝 + perf: parallelize mage preflight with DAG-based execution (27min → 3:24) (#149) (d0b61401)
    • 🔧 - chore: update registry for v0.12.4 (11c8f719) + chore: update registry for v0.12.7 (1de00410)
    @@ -91,24 +95,52 @@ import Layout from '../../../components/Layout.astro';

    - v0.12.4 + v0.12.7

    - +
      +
    • + 🐛 + fix: add GitHub Packages auth to preflight job (24e354fc) +
    • +
    • + 🐛 + fix: add packages:read permission to deploy-website job in release workflow (22ae660d) +
    • +
    • + + feat: limit deps command to azure.yaml services only (#147) (ad5a6218) +
    • +
    • + 🐛 + fix: use correct repo creation dates for LinkedIn publish date (85897788) +
    • +
    • + 🐛 + fix: add LinkedIn publish date via azd-web-core v2.3.0 (86bd8f12) +
    • +
    • + 🐛 + fix: add LinkedIn author meta tag via azd-web-core v2.2.0 (007bba01) +
    • +
    • + + feat: add social media OG images and consistent titles (e59857f2) +
    • 📝 - refactor: use azd-core shared packages, remove 6 wrapper packages (#139) (ee355841) + feat(web): redesign with shared azd-web-core v2 (#146) (026a03f7)
    • 🔧 - chore: remove container auth versions (0.12.0, 0.12.1, 0.12.2) from registry (0145d351) + chore: remove orphaned Header, Footer, ThemeToggle components (0bbd2b3d)
    • 🔧 - chore: update registry for v0.12.3 (f29d30e8) + chore: update registry for v0.12.6 (95204d60)
    diff --git a/web/src/pages/tour/6-logs.astro b/web/src/pages/tour/6-logs.astro index bbd3a782f..f7e43c157 100644 --- a/web/src/pages/tour/6-logs.astro +++ b/web/src/pages/tour/6-logs.astro @@ -53,8 +53,8 @@ const nextStep = getNextStep(6);

    - The azd app logs command provides powerful log viewing and filtering - capabilities for debugging and monitoring your services. + The azd app logs command provides log viewing and filtering + for debugging and monitoring your services.

    View All Logs

    diff --git a/web/src/pages/tour/index.astro b/web/src/pages/tour/index.astro index 07b0e126e..01b623cc7 100644 --- a/web/src/pages/tour/index.astro +++ b/web/src/pages/tour/index.astro @@ -13,7 +13,7 @@ const totalTime = getTotalEstimatedTime();
    @@ -22,7 +22,7 @@ const totalTime = getTotalEstimatedTime();

    Guided Tour

    - Learn azd-app step by step. This comprehensive tour covers installation, + Learn azd-app step by step. This tour covers installation, running apps, using the dashboard, viewing logs, monitoring health, and integrating with GitHub Copilot.