diff --git a/.env.example b/.env.example index 528c4aa..1205dd4 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,21 @@ # make up # # If .env is absent, docker-compose uses the defaults shown here (via ${VAR:-default}). -# The defaults are safe for a local-only stack; change them if you expose -# the stack on a shared network. +# The defaults are PUBLISHED dev values. They are acceptable only because every +# port binds to 127.0.0.1 (see FP_BIND_ADDR below). Replace every *_PASSWORD +# before binding to anything else. # # ============================================================================ +# ---------------------------------------------------------------------------- +# NETWORK EXPOSURE +# ---------------------------------------------------------------------------- +# Address every published port binds to. With a REMOTE docker engine (docker +# context over ssh) this address is on the engine host, not your laptop, and +# 0.0.0.0 there means its LAN and VPN interfaces. Prefer an ssh tunnel +# (ssh -L 6379:127.0.0.1:6379 ) over changing this. +FP_BIND_ADDR=127.0.0.1 + # ---------------------------------------------------------------------------- # POSTGRES # ---------------------------------------------------------------------------- @@ -29,6 +39,16 @@ POSTGRES_PASSWORD=fp MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin +# ---------------------------------------------------------------------------- +# REDIS / NATS (both require auth) +# ---------------------------------------------------------------------------- +# The Go services' local defaults use these same dev values: +# FP_REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:6379 +# FP_NATS_URL=nats://fp:${NATS_PASSWORD}@localhost:4222 +# If you change a password here, export the matching FP_* URL before `go run`. +REDIS_PASSWORD=fp +NATS_PASSWORD=fp + # ---------------------------------------------------------------------------- # GRAFANA # ---------------------------------------------------------------------------- diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4f92ed5..db917c7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,6 +19,10 @@ # that would have caught the failure that kept Security red for a month: # the pinned trivy-action v0.29.0 referenced aquasecurity/setup-trivy@v0.2.2, # a tag upstream deleted, so the job died in "Set up job" in four seconds. +# - docker / helm / docker-compose: the digest-pinned base images, the infra +# images in deploy/, the migrate image in the charts, and the compose +# stack. (Added 2026-10: before that nothing refreshed a single digest pin.) +# - terraform: provider constraints + .terraform.lock.hcl per root module. # # WHY GROUPED UPDATES: # 15 modules x N dependencies of individual PRs is noise nobody reads, and @@ -102,3 +106,115 @@ updates: update-types: - minor - patch + + # --------------------------------------------------------------------------- + # Docker images — base images in the Dockerfiles and the images the raw + # Kubernetes manifests run. + # --------------------------------------------------------------------------- + # WHY: every Dockerfile pins golang:-alpine and distroless by DIGEST, and + # the infra manifests pin postgres/redis/nats/ollama/... by tag AND digest. A + # digest pin is only safe while something refreshes it: without this entry + # the pins silently go stale and the images keep their CVEs forever. + # Dependabot's docker ecosystem reads Dockerfiles and, per GitHub's docs, + # Kubernetes manifests; it rewrites tag and digest together. + - package-ecosystem: docker + directories: + - /services/* + - /deploy/k8s/infra/* + - /deploy/k8s/observability/* + - /deploy/llm + - /deploy/backup + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + commit-message: + prefix: deps(docker) + groups: + docker-minor-and-patch: + update-types: + - minor + - patch + ignore: + # A database major is a data migration (pg_upgrade / dump-restore for + # Postgres, a licence and persistence-format change for Redis 8), not a + # dependency bump. Plan those by hand. + - dependency-name: postgres + update-types: ["version-update:semver-major"] + - dependency-name: redis + update-types: ["version-update:semver-major"] + + # --------------------------------------------------------------------------- + # Helm charts — images referenced in deploy/helm/*/values.yaml (the + # migrate/migrate initContainer, pinned by digest). + # --------------------------------------------------------------------------- + - package-ecosystem: helm + directories: + - /deploy/helm/* + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: deps(helm) + groups: + helm-minor-and-patch: + update-types: + - minor + - patch + + # --------------------------------------------------------------------------- + # docker-compose — the local dev stack (docker-compose.yaml). + # --------------------------------------------------------------------------- + - package-ecosystem: docker-compose + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: deps(compose) + groups: + compose-minor-and-patch: + update-types: + - minor + - patch + ignore: + - dependency-name: postgres + update-types: ["version-update:semver-major"] + - dependency-name: redis + update-types: ["version-update:semver-major"] + + # --------------------------------------------------------------------------- + # pre-commit — the pinned gitleaks hook in .pre-commit-config.yaml. + # --------------------------------------------------------------------------- + - package-ecosystem: pre-commit + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 2 + commit-message: + prefix: deps(pre-commit) + + # --------------------------------------------------------------------------- + # Terraform — provider constraints AND the committed .terraform.lock.hcl, + # one entry per root module. + # --------------------------------------------------------------------------- + # Majors stay separate (an AWS provider major, e.g. 5 -> 6, renames and + # removes arguments and needs a reviewed migration). + - package-ecosystem: terraform + directories: + - /deploy/terraform/bootstrap + - /deploy/terraform/environments/dev + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: deps(terraform) + groups: + terraform-minor-and-patch: + update-types: + - minor + - patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d644ff7..5070cd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,11 +50,15 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -# Pin the toolchain to the go.work `go 1.26.0` directive. The Go toolchain -# refuses to build a workspace whose go directive is newer than the toolchain, -# so this MUST track go.work (bump both together on a Go upgrade). +# Pin the toolchain to the exact patch release in go.work's `go 1.26.10` +# directive. setup-go sets GOTOOLCHAIN=local, so a toolchain older than that +# directive refuses to build the workspace; this MUST track go.work, every +# go.mod and the golang builder image in services/*/Dockerfile (bump all of +# them together on a Go upgrade). A bare "1.26" let setup-go pick whatever +# 1.26.x the runner had cached (1.26.9, which govulncheck flags for +# GO-2026-6809 in crypto/x509). env: - GO_VERSION: "1.26" + GO_VERSION: "1.26.10" jobs: # --------------------------------------------------------------------------- diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e014d4f..f73ed74 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,14 +15,17 @@ # line is in the OSV database." # - Trivy (fs): scans go.sum/lockfiles + IaC/secrets in the working tree — # a broad net for dependency and config issues before anything is built. -# - Trivy (image): scans the BUILT container (OS packages + app deps). Our -# distroless base means this should find ~zero OS CVEs; it gates HIGH/CRITICAL. +# - Trivy (image): scans the BUILT container (OS packages + app deps) as a +# local tarball, BEFORE anything is pushed; `publish` pushes that same +# tarball. Our distroless base means this should find ~zero OS CVEs; it +# gates HIGH/CRITICAL. # - Syft SBOM (CycloneDX): the bill of materials — required for provenance, # consumed by downstream policy (e.g. "no GPL", "no log4j") and audits. -# - cosign keyless: signs the image using a short-lived Sigstore cert tied to -# this workflow's OIDC identity — NO long-lived private key to leak. The -# signature + Rekor transparency-log entry let anyone verify the image came -# from THIS repo's release workflow. +# - cosign keyless: signs every published image (main and v* tags) using a +# short-lived Sigstore cert tied to this workflow's OIDC identity — NO +# long-lived private key to leak. The signature + Rekor transparency-log +# entry let anyone (and the Kyverno verifyImages policy) verify the image +# came from THIS repo's Security workflow on main or a release tag. # # STRUCTURE — onboarding a service costs nothing here: # Both matrices are RESOLVED FROM THE REPO by the `matrix` job below — @@ -50,24 +53,39 @@ on: # Allow on-demand runs (e.g. to re-scan after a base-image CVE disclosure) # without a code change. workflow_dispatch: + # Weekly re-scan with a fresh vulnerability DB, plus the third-party image + # scan below, which only runs on schedule/dispatch. + schedule: + - cron: "17 4 * * 1" -# SECURITY: least privilege at the TOP. The default for every job is read-only; -# the two jobs that need more elevate locally: -# - The image job adds id-token:write (Sigstore OIDC for keyless cosign) and -# packages:write (push to GHCR) ONLY when it actually signs/pushes. +# SECURITY: least privilege at the TOP. The default for every job is read-only. +# Exactly one job elevates: `publish`, which runs only for pushes to main or a +# v* tag (never for pull_request or workflow_dispatch), adds packages:write +# (push to GHCR) and id-token:write (Sigstore OIDC for keyless cosign). The +# `image` job, which builds pull-request code, keeps contents:read. permissions: contents: read +# The event is part of the group. GitHub keeps at most one PENDING run per +# group and cancels it when another run queues, whatever cancel-in-progress +# says. Without the event, the weekly schedule run (ref = main) could queue +# behind an in-progress push run and cancel a pending push run, so that commit +# would never be published or signed. Schedule and dispatch runs now queue in +# their own groups and never displace a push. concurrency: - group: security-${{ github.workflow }}-${{ github.ref }} + group: security-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: - GO_VERSION: "1.26" - # Container registry + image namespace. GHCR keyed to the repo owner so - # `cosign verify` can assert the image came from this org's workflows. + # Exact patch release, kept equal to go.work, every go.mod and the golang + # builder image in services/*/Dockerfile. govulncheck reports standard-library + # vulnerabilities against THIS toolchain, so a bare "1.26" made the result + # depend on whichever 1.26.x the runner had cached. + GO_VERSION: "1.26.10" REGISTRY: ghcr.io - IMAGE_NAMESPACE: ${{ github.repository_owner }} + # Every image is ghcr.io///, the name the Helm charts, + # Rollouts and the Kyverno allowlist use (ghcr.io/abd-ulbasit/forgepoint/auth). + IMAGE_PREFIX: ghcr.io/${{ github.repository }} jobs: # =========================================================================== @@ -168,9 +186,25 @@ jobs: # cannot break this gate the same way again. - name: Trivy filesystem scan uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + # Render the Helm charts against a Kubernetes version they accept. + # Trivy renders charts as Kubernetes v1.20.0 by default, below every + # chart's kubeVersion (>=1.23.0-0), so it logged "Failed to render + # Chart files" for all 12 charts and scanned NONE of them, and the job + # still passed on that part. Keep this in step with the EKS + # cluster_version in deploy/terraform. + TRIVY_HELM_KUBE_VERSION: "1.36.0" with: scan-type: fs scan-ref: . + # Evaluate the Terraform with the values people actually copy. Trivy + # does not read *.tfvars.example on its own, which is how an example + # opening the EKS API to 0.0.0.0/0 sat in the repo with the gate + # green (AWS-0040/AWS-0041 fire on it when this is set). + tf-vars: deploy/terraform/environments/dev/terraform.tfvars.example + # Accepted findings: every entry has a reason and an expiry date + # (see .trivyignore.yaml and SECURITY.md "Accepted findings"). + trivyignores: .trivyignore.yaml # Gate the build on actionable severities only; INFO/LOW noise would # train people to ignore the gate. severity: HIGH,CRITICAL @@ -179,74 +213,174 @@ jobs: # NOTE: ignore-unfixed has been intentionally removed. Keeping it would # silently pass HIGH/CRITICAL CVEs that have no upstream fix yet — # exactly the ones you most need visibility on. An unfixed CVE is a - # real risk; we want the gate to be honest. If a specific CVE must be - # accepted (e.g. no fix exists and a compensating control is in place), - # add it to a `.trivyignore` file with a documented expiry date. + # real risk; we want the gate to be honest. If a specific finding must + # be accepted, add it to .trivyignore.yaml with a statement and an + # expired_at date; after that date the gate goes red again. # Cover dependency CVEs, leaked secrets, and IaC misconfig in one pass. scanners: vuln,secret,misconfig # =========================================================================== - # 3. Image: build → Trivy image scan → SBOM → cosign sign (per service). + # 2b. gitleaks — secrets in the FULL git history, not just the working tree. + # =========================================================================== + # Trivy's secret scanner (above) only sees the current checkout. A credential + # committed and later deleted is invisible to it, yet still in every clone. + # This job walks every commit. Findings fail the gate; a known-safe value is + # allowlisted in .gitleaks.toml with a reason, a real one is ROTATED (removing + # it from history does not un-leak it). + # + # gitleaks is built from source at a pinned version with `go install`, so the + # module checksum database verifies it (the same way govulncheck is installed + # above) instead of trusting a downloaded binary or a third-party action. + # --redact keeps any finding's value out of the job log. + gitleaks: + name: gitleaks (full history) + runs-on: ubuntu-latest + steps: + - name: Checkout (full history) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Install gitleaks + run: go install github.com/zricethezav/gitleaks/v8@v8.30.1 + + - name: Scan every commit + run: gitleaks git --config .gitleaks.toml --redact --verbose --exit-code 1 . + + # =========================================================================== + # 2c. third-party images — the images we RUN but do not build. + # =========================================================================== + # The image job scans only first-party services. postgres, redis, nats, + # grafana, prometheus, tempo, loki, the OTel collector, ollama and the + # migrate initContainer also run in the cluster and were never scanned. The + # list is derived from the repo (every `image: ...@sha256:` in deploy/ and + # docker-compose.yaml, plus repository/tag@sha256 pairs in chart values), so + # a newly pinned image is covered automatically. The minio images are absent + # because they cannot be digest-pinned any more (see deploy/k8s/infra/minio). + # + # Runs on the weekly schedule and on demand, NOT on push/PR: these CVEs are + # fixed by bumping a pin (Dependabot's docker ecosystem proposes those), not + # by the change under review, so they must not block unrelated merges. A red + # scheduled run is the alert. + # + # Unlike the first-party gates this uses ignore-unfixed: an upstream CVE with + # no fixed release cannot be acted on by bumping, so reporting it weekly would + # only train people to ignore the run. Accept anything else via + # .trivyignore.yaml (statement + expiry) like every other finding. + third-party-matrix: + name: resolve third-party images + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + outputs: + images: ${{ steps.set.outputs.images }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve + id: set + run: | + set -euo pipefail + images=$( { + grep -rhoE '^[[:space:]]*image:[[:space:]]*"?[^"[:space:]]+@sha256:[0-9a-f]{64}' deploy docker-compose.yaml \ + | sed -E 's/^[[:space:]]*image:[[:space:]]*"?//' + for f in deploy/helm/*/values.yaml; do + awk '/^[[:space:]]+repository:/{r=$2} /^[[:space:]]+tag:.*@sha256:/{print r":"$2}' "$f" + done + } | sort -u | jq -R . | jq -sc . ) + [ "$(jq -r 'length' <<<"$images")" -gt 0 ] || { echo "no digest-pinned third-party images found" >&2; exit 1; } + echo "images=$images" >> "$GITHUB_OUTPUT" + jq -r '.[]' <<<"$images" + + third-party-images: + name: third-party (${{ matrix.image }}) + needs: [third-party-matrix] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: ${{ fromJSON(needs.third-party-matrix.outputs.images) }} + steps: + - name: Checkout (for .trivyignore.yaml) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Trivy image scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: image + image-ref: ${{ matrix.image }} + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + trivyignores: .trivyignore.yaml + + # =========================================================================== + # 3. image — build → Trivy image scan → SBOM, per service. NEVER pushes. # =========================================================================== + # ORDER OF OPERATIONS (the point of splitting this from `publish`): + # build to a local tarball → scan that tarball → SBOM it → (main/tags only) + # hand the SAME tarball to `publish`. Nothing reaches the registry unless + # the scan of those exact bytes passed. The old single job pushed on every + # push event BEFORE Trivy ran, and scanned the first mutable tag (:main) + # rather than what it had just built, so an image with a HIGH CVE was + # already pullable by the time the gate went red. + # + # PERMISSIONS: contents:read only. This job builds code from pull requests, + # so it must not hold packages:write or id-token:write. (It used to hold both + # statically, on same-repo PR runs too.) image: name: image (${{ matrix.service }}) runs-on: ubuntu-latest - needs: [matrix, govulncheck, trivy-fs] + needs: [matrix, govulncheck, trivy-fs, gitleaks] strategy: fail-fast: false matrix: service: ${{ fromJSON(needs.matrix.outputs.services) }} permissions: contents: read - # Push images to GHCR (only used on push/tag, see the `if` on push/sign). - packages: write - # Sigstore keyless signing: mints a short-lived OIDC token as the cert - # subject. WITHOUT this, cosign would need a long-lived key (the thing we - # are deliberately avoiding). - id-token: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Buildx gives us the modern BuildKit builder (cache mounts in the - # Dockerfile, multi-stage, provenance) used by build-push-action. + # Dockerfile, multi-stage) used by build-push-action. - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - # Derive tags/labels from git context. On a PR we build but do NOT push - # (no login below); on push/tag we push and sign. metadata-action emits a - # canonical ${{ steps.meta.outputs.tags }} + OCI labels. + # Tags/labels from git context. The image NAME is the one the Helm charts, + # Rollouts and Kyverno allowlist already use: + # ghcr.io/// e.g. ghcr.io/abd-ulbasit/forgepoint/auth + # (it used to be ghcr.io//forgepoint-, which nothing that + # deploys images referenced). - name: Docker metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/forgepoint-${{ matrix.service }} + images: ${{ env.IMAGE_PREFIX }}/${{ matrix.service }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=sha,format=long - # Log in to GHCR ONLY when we intend to push (push to main or a tag). - # On PRs from forks the GITHUB_TOKEN can't push anyway; gating the login - # keeps PR runs read-only and avoids a useless auth attempt. - - name: Log in to GHCR - if: github.event_name == 'push' - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Build the image from the REPO ROOT context (context: .) with the - # service's Dockerfile. Root context is mandatory: the Dockerfile copies - # sibling modules (pkg, gen/go) and generates a per-service go.work on the - # fly — those paths don't exist in a services// context. See the long - # header in services//Dockerfile for the full rationale. - # - load:true on PR → image stays local for the Trivy scan, no push. - # - push only on a real push event. - # - digest output feeds the SBOM + cosign steps (sign by digest, not tag, - # so the signature is immutable even if the tag is later re-pointed). + # Build from the REPO ROOT context (the Dockerfile copies sibling modules: + # pkg, gen/go). Output is a docker-archive TARBALL, not a push and not a + # registry ref: the scan and the SBOM below read this file, and `publish` + # pushes this file. provenance/sbom attestations are off because the + # docker exporter cannot carry them (Syft's SBOM below is attached with + # cosign instead). - name: Build image id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 @@ -255,59 +389,37 @@ jobs: file: services/${{ matrix.service }}/Dockerfile tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - push: ${{ github.event_name == 'push' }} - load: ${{ github.event_name != 'push' }} + outputs: type=docker,dest=${{ runner.temp }}/image.tar + provenance: false + sbom: false build-args: | VERSION=${{ github.ref_name }} cache-from: type=gha cache-to: type=gha,mode=max - # Scan the BUILT image. On push we built with push:true (no local image), - # so scan the pushed ref; on PR we scan the locally loaded tag. Either way - # we reference the first metadata tag. FAIL on HIGH/CRITICAL — distroless - # should keep this green; a red here means a dependency CVE shipped. - - name: Resolve scan target - id: target - # SECURITY: steps.meta.outputs.tags is derived from git context (branch - # name, PR ref, SHA). Interpolating ${{ ... }} directly into `run:` shell - # is a script-injection vector — a maliciously crafted branch name could - # embed shell metacharacters or commands that execute in the runner. - # Fix: pass the value through an environment variable (env:) instead of - # inline interpolation. The shell receives it as a plain string ($TAGS), - # so metacharacters are never interpreted by bash. This is the standard - # GitHub Actions mitigation for CWE-78 / GHSA script injection. - env: - TAGS: ${{ steps.meta.outputs.tags }} - run: | - # Take the first line of the multi-line tags output as the scan ref. - ref="$(printf '%s\n' "${TAGS}" | head -n1)" - echo "image=${ref}" >> "$GITHUB_OUTPUT" - + # Scan the BUILT bytes (the tarball), not a tag. FAIL on HIGH/CRITICAL. - name: Trivy image scan uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: image - image-ref: ${{ steps.target.outputs.image }} + input: ${{ runner.temp }}/image.tar severity: HIGH,CRITICAL exit-code: "1" # NOTE: ignore-unfixed has been intentionally removed — same rationale - # as the fs scan above. A HIGH/CRITICAL with no upstream fix is still a - # real risk that deserves a decision, not a silent pass. Add a - # .trivyignore entry with justification if a specific CVE must be - # accepted (e.g. distroless ships an unfixed low-risk OpenSSL build). + # as the fs scan above. Accept a specific CVE only through + # .trivyignore.yaml (statement + expiry). vuln-type: os,library + trivyignores: .trivyignore.yaml - # Generate a CycloneDX SBOM from the built image. anchore/sbom-action wraps - # Syft; CycloneDX is the format policy engines + `cosign attest` consume. - # Uploaded as an artifact (and as a release asset on a tag). + # CycloneDX SBOM of the same tarball. Uploaded as an artifact on every + # run; attached to the pushed image as a signed attestation in `publish`. - name: Generate CycloneDX SBOM (Syft) uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: - image: ${{ steps.target.outputs.image }} + image: docker-archive:${{ runner.temp }}/image.tar format: cyclonedx-json output-file: sbom-${{ matrix.service }}.cyclonedx.json - # Don't auto-publish to a GitHub release on tags from here; we attach - # explicitly so the artifact name is deterministic. + upload-artifact: false upload-release-assets: false - name: Upload SBOM artifact @@ -317,40 +429,162 @@ jobs: path: sbom-${{ matrix.service }}.cyclonedx.json if-no-files-found: error - # Install cosign for keyless signing. - - name: Install cosign - # Sign only on TAGGED release builds. We don't sign every main push — - # signatures are a release artifact; signing dev images is noise. + # Hand the scanned tarball (and its config digest = image ID) to + # `publish`. Only on events that publish, so PR runs do not store + # 12 images. One-day retention: this is a hand-off, not an archive. + - name: Record scanned image ID + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + env: + IMAGE_ID: ${{ steps.build.outputs.imageid }} + run: | + set -euo pipefail + [ -n "${IMAGE_ID}" ] || { echo "build produced no image id" >&2; exit 1; } + mkdir -p "${RUNNER_TEMP}/publish" + mv "${RUNNER_TEMP}/image.tar" "${RUNNER_TEMP}/publish/image.tar" + printf '%s\n' "${IMAGE_ID}" > "${RUNNER_TEMP}/publish/scanned-imageid.txt" + + - name: Upload scanned image for publish + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: image-${{ matrix.service }} + path: ${{ runner.temp }}/publish/ + if-no-files-found: error + retention-days: 1 + + # =========================================================================== + # 4. publish — push the SCANNED image, then sign it. main and v* tags only. + # =========================================================================== + # Runs only after `image` passed for every service (a matrix `needs` waits + # for all legs), only for pushes to main or a v* tag, and only here do the + # write permissions exist: + # packages:write — push to GHCR with GITHUB_TOKEN + # id-token:write — Sigstore keyless signing (a short-lived Fulcio cert bound + # to this workflow's OIDC identity; no long-lived key) + # pull_request and workflow_dispatch runs never reach this job. + publish: + name: publish (${{ matrix.service }}) + runs-on: ubuntu-latest + needs: [matrix, image] + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + strategy: + fail-fast: false + matrix: + service: ${{ fromJSON(needs.matrix.outputs.services) }} + permissions: + contents: read + packages: write + id-token: write + steps: + # Full history so the tag check below can see main. + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + # A v* tag can point at ANY commit, including one that never went through + # review on main. Publish (and sign) only commits that are on main. + - name: Refuse tags that are not on main if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "::error::${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not on main; refusing to publish" >&2 + exit 1 + fi + + - name: Download scanned image + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: image-${{ matrix.service }} + path: scanned + + - name: Download SBOM + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: sbom-${{ matrix.service }} + path: sbom + + # Prove the tarball is the image that was scanned, then load it. The + # "image ID" buildx reported is the digest of the image CONFIG, which + # lists every layer's uncompressed digest: same config digest, same + # layers. It is recomputed from the tarball itself, so the check does not + # depend on which image store the runner's Docker uses. + - name: Verify and load the scanned image + id: load + env: + SERVICE: ${{ matrix.service }} + run: | + set -euo pipefail + expected="$(cat scanned/scanned-imageid.txt)" + config_path="$(tar -xOf scanned/image.tar manifest.json | jq -r '.[0].Config')" + actual="sha256:$(tar -xOf scanned/image.tar "${config_path}" | sha256sum | cut -d' ' -f1)" + if [ "${actual}" != "${expected}" ]; then + echo "::error::tarball config ${actual} != scanned image ${expected}" >&2 + exit 1 + fi + docker load --input scanned/image.tar + repo="$(printf '%s/%s' "${IMAGE_PREFIX}" "${SERVICE}" | tr '[:upper:]' '[:lower:]')" + echo "repo=${repo}" >> "$GITHUB_OUTPUT" + echo "image_id=${expected}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Push every tag metadata-action put on the image (main or the semver, + # plus sha-<40>), then ask the REGISTRY for the manifest digest of the + # sha-<40> tag and check that manifest's config is the scanned image. + - name: Push + id: push + env: + REPO: ${{ steps.load.outputs.repo }} + IMAGE_ID: ${{ steps.load.outputs.image_id }} + run: | + set -euo pipefail + docker push --all-tags "${REPO}" + digest="$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${REPO}:sha-${GITHUB_SHA}" | jq -r .digest)" + case "${digest}" in sha256:*) ;; *) echo "::error::no digest for ${REPO}:sha-${GITHUB_SHA}" >&2; exit 1 ;; esac + remote_config="$(docker buildx imagetools inspect --raw "${REPO}@${digest}" | jq -r .config.digest)" + if [ "${remote_config}" != "${IMAGE_ID}" ]; then + echo "::error::pushed config ${remote_config} != scanned image ${IMAGE_ID}" >&2 + exit 1 + fi + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + echo "pushed ${REPO}@${digest}" + + - name: Install cosign uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - # Keyless sign the image BY DIGEST. COSIGN_EXPERIMENTAL/--yes drive the - # OIDC keyless flow: cosign requests a token from GitHub's OIDC provider - # (enabled by id-token:write), Fulcio issues a short-lived cert bound to - # this workflow's identity, the signature + cert land in Rekor (public - # transparency log). Verifiers run: - # cosign verify --certificate-identity-regexp '...forgepoint/.github/...' \ - # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - # ghcr.io//forgepoint-@ - # We sign the DIGEST (immutable), not a tag, so re-pointing the tag can't - # invalidate or hijack the signature. + # Keyless sign the image BY DIGEST: cosign exchanges this job's GitHub + # OIDC token for a short-lived Fulcio cert, and the signature + cert land + # in the public Rekor log. Every published image is signed, main builds + # included, so the cluster can require a signature on ANY image from this + # repository (deploy/policies/base/50-verify-images.yaml). Verify with: + # cosign verify \ + # --certificate-identity-regexp '^https://github\.com/abd-ulbasit/forgepoint/\.github/workflows/security\.yml@refs/(heads/main|tags/v.*)$' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + # ghcr.io/abd-ulbasit/forgepoint/@ - name: Cosign sign (keyless) - if: startsWith(github.ref, 'refs/tags/v') env: - DIGEST: ${{ steps.build.outputs.digest }} - IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/forgepoint-${{ matrix.service }} + REPO: ${{ steps.load.outputs.repo }} + DIGEST: ${{ steps.push.outputs.digest }} run: | - cosign sign --yes "${IMAGE}@${DIGEST}" + cosign sign --yes "${REPO}@${DIGEST}" - # Attach the SBOM to the image as a signed CycloneDX attestation so the - # bill of materials travels WITH the image and is itself verifiable. + # Attach the SBOM as a signed CycloneDX attestation so the bill of + # materials travels WITH the image and is itself verifiable. - name: Cosign attest SBOM (keyless) - if: startsWith(github.ref, 'refs/tags/v') env: - DIGEST: ${{ steps.build.outputs.digest }} - IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/forgepoint-${{ matrix.service }} + REPO: ${{ steps.load.outputs.repo }} + DIGEST: ${{ steps.push.outputs.digest }} + SERVICE: ${{ matrix.service }} run: | cosign attest --yes \ - --predicate "sbom-${{ matrix.service }}.cyclonedx.json" \ + --predicate "sbom/sbom-${SERVICE}.cyclonedx.json" \ --type cyclonedx \ - "${IMAGE}@${DIGEST}" + "${REPO}@${DIGEST}" diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..3440082 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,102 @@ +# ============================================================================= +# TERRAFORM — offline checks for every root module in deploy/terraform +# ============================================================================= +# +# Until this workflow existed nothing ran `terraform fmt` or `validate` in CI, +# although environments/dev/backend.tf said CI did; the only thing that read +# the .tf files was Trivy's misconfiguration scanner. +# +# WHAT IT CHECKS (no cloud credentials, nothing is planned or applied): +# - fmt -check: formatting drift fails the build. +# - init -backend=false -lockfile=readonly: providers are installed from the +# COMMITTED .terraform.lock.hcl and must match its checksums. A constraint +# change without a regenerated lock file fails here instead of silently +# resolving a different provider build. +# - validate: syntax, references, types and variable validation rules. +# +# WHAT IT DOES NOT DO: `plan`. There are no AWS credentials in this repo's CI +# and nothing has been applied, so a plan would have no state to compare to. +# When an environment goes live, add a plan job that assumes a read-only role +# through GitHub OIDC (see modules/github-oidc for the trust pattern). +# +# No path filter on purpose: if this check is ever made required in branch +# protection, a path-filtered workflow never reports on unrelated PRs and +# blocks them forever. +# ============================================================================= + +name: Terraform + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +# Nothing by default; the jobs ask for read access to the repo and no more. +permissions: {} + +concurrency: + group: terraform-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + # The version the lock files and README are validated against. + TERRAFORM_VERSION: "1.14.8" + +jobs: + fmt: + name: terraform fmt + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + terraform_wrapper: false + + - name: terraform fmt -check + run: terraform fmt -check -recursive -diff deploy/terraform + + validate: + name: validate (${{ matrix.root }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + # Every ROOT module (a directory you run terraform in). Modules under + # deploy/terraform/modules are validated through the roots that call + # them. Add staging/prod here when they exist. + root: + - deploy/terraform/bootstrap + - deploy/terraform/environments/dev + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + terraform_wrapper: false + + - name: terraform init (no backend, lock file read-only) + env: + ROOT: ${{ matrix.root }} + run: terraform -chdir="${ROOT}" init -backend=false -input=false -lockfile=readonly + + - name: terraform validate + env: + ROOT: ${{ matrix.root }} + run: terraform -chdir="${ROOT}" validate -no-color diff --git a/.gitignore b/.gitignore index 9a8ce2b..2acca4b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,8 @@ Thumbs.db *.tfstate *.tfstate.backup .terraform/ -.terraform.lock.hcl +# NOTE: .terraform.lock.hcl is deliberately NOT ignored. It pins the exact +# provider versions and their checksums; see deploy/terraform/.gitignore. # Helm packaging artifacts deploy/helm/*/charts/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..687da60 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,38 @@ +# ============================================================================= +# gitleaks configuration — used by the Security workflow (full git history) and +# the pre-commit hook (staged changes). +# ============================================================================= +# Start from gitleaks' built-in rules and add a NARROW allowlist of published +# development placeholders. Every value below is deliberately public, appears +# in docs or committed dev values, and is never a working credential anywhere. +# Do not add a real secret here "temporarily": rotate it instead. +# ============================================================================= + +title = "forgepoint" + +[extend] +useDefault = true + +[[allowlists]] +description = "Published dev/test placeholders, never valid credentials" +# Matched against the extracted SECRET (gitleaks' default regexTarget). +regexes = [ + # dev-only-change-me* placeholders in chart values and placeholder Secrets. + # This also covers the old dev JWT placeholder, which is public (git history + # and the fp-*/_helpers.tpl denylist, which makes the charts refuse it). Any + # environment that ever used it as its signing key must rotate (SECURITY.md). + '''^dev-only-change-me(-[a-z0-9-]+)?$''', + '''^changeme-[a-z0-9-]+$''', + # The fixed HMAC key used by pkg/auth unit tests. + '''^unit-test-jwt-secret-32bytes-ok!$''', +] + +[[allowlists]] +description = "False positive: the C4 diagram's technology label for the API gateway" +# generic-api-key matches `Container(gateway_edge, "API Gateway", "Traefik/Kong", ...)` +# in docs/diagrams/c4-architecture.md (commit d553f7fe, line 112) and captures +# the label "Traefik/Kong" as a key. Both conditions must hold (AND): this exact +# value AND this one file, so the same string anywhere else is still scanned. +condition = "AND" +paths = ['''^docs/diagrams/c4-architecture\.md$'''] +regexes = ['''^Traefik/Kong$'''] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..81d8321 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +# Local secret scanning before a commit exists. Install once per clone: +# pipx install pre-commit (or: brew install pre-commit) +# pre-commit install +# The same gitleaks rules run in CI over the FULL history (security.yml), so +# skipping the hook (git commit --no-verify) only moves the failure to the PR. +# rev is pinned to a commit SHA (tag in the comment); Dependabot's pre-commit +# ecosystem keeps it current. +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1 + hooks: + - id: gitleaks diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000..e3af4f8 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,45 @@ +# ============================================================================= +# Trivy accepted findings — every entry needs a reason AND an expiry. +# ============================================================================= +# Read by the Security workflow (trivyignores: .trivyignore.yaml) for both the +# filesystem and the image scans. Policy (SECURITY.md, "Accepted findings"): +# - an entry is a DECISION, not a mute button: `statement` says why the +# finding is a false positive or why the risk is accepted, and what would +# remove the entry; +# - `expired_at` is mandatory; after it, Trivy stops honouring the entry and +# the gate goes red again, forcing a re-decision; +# - scope every entry with `paths` (or a package/CVE) as tightly as possible, +# never a bare rule ID for the whole repo. +# ============================================================================= + +misconfigurations: + # FALSE POSITIVE. KSV-0109 ("ConfigMap with secrets") matches the key name + # FP_TEAM_TOKEN_BUDGET because it contains "TOKEN". The value is an integer + # token-count budget for LLM usage ("1000000"), not a credential. Renaming the + # env var would change the service's configuration contract to silence a + # heuristic. Re-check on expiry (or if the ConfigMap gains new keys). + - id: KSV-0109 + paths: + - "deploy/helm/fp-ai-gateway/templates/configmap.yaml" + statement: >- + False positive: FP_TEAM_TOKEN_BUDGET is an integer LLM token budget, not a + secret. The ConfigMap holds no credentials (those are in the chart Secret). + expired_at: 2027-04-30 + + # ACCEPTED RISK, TIME-BOXED. The backup CronJob cannot run with a read-only + # root FS as written, because its script installs tools at runtime + # (`apk add`, `curl ... -o /usr/local/bin/mc`). That script is ALSO broken + # independently of this finding: it runs as uid 999, which cannot apk add or + # write /usr/local/bin, its database names do not match + # scripts/init-postgres.sh, and the minio/mc image it would need is gone from + # Docker Hub. The fix is a rewrite (tools baked into a pinned image, no + # runtime downloads, then readOnlyRootFilesystem: true), tracked in + # SECURITY.md. Compensating controls: non-root, drop ALL, no privilege + # escalation, seccomp RuntimeDefault, no SA token. + - id: KSV-0014 + paths: + - "deploy/backup/postgres-backup-cronjob.yaml" + statement: >- + Backup job installs tools at runtime, so it cannot be read-only until it + is rewritten (see SECURITY.md). Non-root, drop ALL, no privesc, seccomp. + expired_at: 2027-01-31 diff --git a/Makefile b/Makefile index 0ea51c3..8af9c17 100644 --- a/Makefile +++ b/Makefile @@ -346,6 +346,9 @@ kind-load: ## Load Docker image into Kind: make kind-load SVC=auth # Helm # ============================================================================ +# Local Kind installs render the chart's own dev Secret (secrets.create=true). +# The chart default is false so that a real install fails closed on a missing +# Secret instead of shipping the committed dev placeholders. .PHONY: helm-install helm-install: ## Install service via Helm: make helm-install SVC=auth @if [ -z "$(SVC)" ]; then echo "ERROR: specify SVC="; exit 1; fi @@ -353,6 +356,7 @@ helm-install: ## Install service via Helm: make helm-install SVC=auth helm install fp-$(SVC) deploy/helm/fp-$(SVC)/ \ --namespace $(NAMESPACE) \ --create-namespace \ + --set secrets.create=true \ --set image.repository=$(REGISTRY)-$(SVC) \ --set image.tag=$(IMAGE_TAG) @@ -362,6 +366,7 @@ helm-upgrade: ## Upgrade service via Helm: make helm-upgrade SVC=auth @echo "==> Upgrading $(SVC) via Helm in namespace $(NAMESPACE)..." helm upgrade fp-$(SVC) deploy/helm/fp-$(SVC)/ \ --namespace $(NAMESPACE) \ + --set secrets.create=true \ --set image.repository=$(REGISTRY)-$(SVC) \ --set image.tag=$(IMAGE_TAG) diff --git a/README.md b/README.md index 55d2694..adb58f0 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Reusable building blocks every service imports: ## Getting started -Prerequisites: Go 1.26+, Docker, `make`. +Prerequisites: Go 1.26.10+ (the `go` directive in `go.work`; an older Go downloads 1.26.10 unless `GOTOOLCHAIN=local`), Docker, `make`. ```bash # Start local infrastructure (NATS, Postgres, Redis, MinIO, monitoring) diff --git a/SECURITY.md b/SECURITY.md index 377a09c..21139c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,12 +17,13 @@ contracts, the Helm values, and the CLI flags alike as unstable until there is a ## What CI enforces The [`Security`](.github/workflows/security.yml) workflow runs on every push to -`main`, every pull request, every `v*` tag, and on demand. Four gates, in order: +`main`, every pull request, every `v*` tag, and on demand. The gates: | Gate | Tool | Fails on | |---|---|---| | Go dependencies | [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) v1.1.4, one leg per module in `go.work` | any **reachable** vulnerability | | Working tree | Trivy `fs` — `vuln,secret,misconfig` | HIGH / CRITICAL | +| Git history | [`gitleaks`](https://github.com/gitleaks/gitleaks) v8.30.1 over **every commit** (`.gitleaks.toml`) | any secret not in the allowlist | | Built image | Trivy `image` — `os,library` | HIGH / CRITICAL | | Shipped web deps | `npm audit --omit=dev` (in [`CI`](.github/workflows/ci.yml)) | HIGH / CRITICAL | @@ -31,10 +32,14 @@ build actually reaches the vulnerable symbol. That is the reason this gate is worth failing on — it is not "a line in `go.sum` matches an OSV record". Trivy runs **without** `ignore-unfixed`. An advisory with no upstream fix is -still a real risk and deserves a decision, not a silent pass. There is currently -no `.trivyignore` in this repo and no accepted-advisory allowlist; if one becomes -necessary, each entry must carry a written justification and an expiry date, and -this file is where it gets documented. +still a real risk and deserves a decision, not a silent pass. Accepted findings +live in [`.trivyignore.yaml`](.trivyignore.yaml), never in a bare `.trivyignore` +(see "Accepted findings" below). + +The filesystem scan also renders the Helm charts (`TRIVY_HELM_KUBE_VERSION`; +before that was set, Trivy failed to render all 12 charts and silently skipped +them) and evaluates the Terraform with `terraform.tfvars.example` (`tf-vars`), +because Trivy does not read `*.example` files on its own. Known and deliberately out of scope of the gates above: the build toolchain in `web/` (vite, esbuild) carries dev-server advisories, and `react-router` 6.x @@ -42,40 +47,157 @@ carries two MODERATE advisories whose only fix is the 7.x major. Neither ships i the production bundle; the `npm audit` gate is scoped to production dependencies for that reason, and Dependabot proposes the majors as reviewable PRs. -## Open findings +### Secrets in history + +Trivy's secret scanner only sees the current checkout, so a credential that was +committed and later deleted passed every gate while remaining in every clone. +The `gitleaks` job scans the full history, and the same rules run locally as a +pre-commit hook (`.pre-commit-config.yaml`; `pre-commit install`). The first +history run may surface old findings. Triage each one: rotate a real +credential (deleting it from history does not un-leak it), and allowlist a +published placeholder in `.gitleaks.toml` with a comment. + +One such placeholder is already known: the old dev JWT key +`dev-o****` (the full value is in the `fp-*/_helpers.tpl` denylist). It was +committed as a chart default, so it is public. The charts refuse to render +with it, but **any environment that ever used it as its HS256 signing key must +rotate that key**, because anyone can mint tokens with it. Purging it from +history would need a force-push and does not help anyone who has already +cloned the repo, so it is allowlisted rather than purged. + +The first full-history run (gitleaks v8.30.1, 141 commits) found exactly one +other hit, a false positive: `generic-api-key` matched the C4 diagram's +technology label `Traefik/Kong` in `docs/diagrams/c4-architecture.md` (commit +`d553f7fe`, line 112). It is allowlisted for that value in that one file only +(`condition = "AND"`), so the same string elsewhere is still reported. With +that entry, `gitleaks git --config .gitleaks.toml --exit-code 1 .` exits 0. + +### Third-party images + +The images the cluster runs but this repo does not build (postgres, redis, +nats, grafana, prometheus, tempo, loki, the OTel collector, ollama, migrate) +are pinned by tag **and** digest, refreshed by Dependabot's docker/helm/compose +ecosystems, and scanned weekly (and on demand) by the `third-party-images` job. +That job runs on schedule only, so a new upstream CVE never blocks an unrelated +PR, and it uses `ignore-unfixed`, because a CVE with no fixed release cannot be +fixed by bumping a pin. A red scheduled run is the signal to bump. + +## What the gates do not see + +Each gate is narrower than its name suggests. Known blind spots, so nobody +reads a green run as more than it is: + +| Gate | Catches | Misses | +|---|---|---| +| govulncheck | Go vulnerabilities your code **reaches** | Non-Go code; vulnerable code reached only via reflection or `unsafe`; anything not in the Go vuln DB yet | +| Trivy `fs` (vuln) | CVEs in `go.mod`/`go.sum` and `package-lock.json` | npm **devDependencies** (Trivy skips them unless `--include-dev-deps`), so the vite/esbuild toolchain is not covered | +| Trivy `fs` (misconfig) | Terraform, Kubernetes YAML, Helm charts **rendered with default values**, Dockerfiles | Values passed at deploy time (`--set`, Argo CD parameters, real `terraform.tfvars`); only the example tfvars is evaluated | +| Trivy `fs` (secret) | Secrets in the **current checkout** | History (that is gitleaks' job) and formats without a rule | +| gitleaks | Secrets in **every commit**, by rule and entropy | Formats no rule knows; anything allowlisted in `.gitleaks.toml` | +| Trivy `image` | OS + library CVEs in the **first-party** images, before push | Third-party images (scanned weekly by `third-party-images`, not per PR); MinIO, which can no longer be pinned | +| `npm audit` | HIGH/CRITICAL in **production** web deps | Dev deps (deliberately `--omit=dev`); MODERATE advisories | +| Terraform workflow | Formatting, syntax, references, variable validation, lock-file drift | Anything a `plan` would show: there are no AWS credentials in CI and nothing has been applied | +| Kyverno (cluster) | Pod settings, registries, tags, image signatures | Everything ships in **Audit**, so nothing is blocked until the enforce overlay is applied; `fp-ml` (ollama) is outside the matched namespaces | + +And one gap no workflow can close: **`main` has no branch protection or required +checks**, so a red Security, CI or Terraform run blocks nothing. Making these +gates real is a repository-settings change (protect `main`, require the checks). + +## Triage of the IaC findings (opened 2026-07-27, decided 2026-10-24) The Trivy filesystem gate had never executed before 2026-07-27 — it referenced an action tag that upstream deleted, so the job died in setup. The first run -that got past setup surfaced eight HIGH **IaC misconfigurations**, all -pre-existing. They are listed here rather than suppressed, and the gate stays -red until they are resolved: +that got past setup surfaced HIGH **IaC misconfigurations**, all pre-existing, +and the gate stayed red on them for three months. Each one now has a decision: -| Rule | Where | Fix | +| Rule | Where | Decision | |---|---|---| -| KSV-0014 | `deploy/k8s/infra/postgres/postgres.yaml`, `deploy/backup/postgres-backup-cronjob.yaml`, `deploy/k8s/observability/grafana/grafana.yaml`, `deploy/llm/ollama.yaml` | `securityContext.readOnlyRootFilesystem: true` plus writable `emptyDir` mounts for each image's scratch paths | -| KSV-0118 | `deploy/llm/ollama.yaml` | give the ollama pod/container a non-default `securityContext` | -| AWS-0164 (x3) | `deploy/terraform/modules/network/main.tf:79` | `map_public_ip_on_launch = false` on the public subnets | -| AWS-0132 | `deploy/terraform/modules/state-backend/main.tf:33-41` | SSE-KMS with a customer-managed key instead of SSE-S3 | - -Three of the KSV-0014 sites carry in-file comments saying `readOnlyRootFilesystem` -was left unset on purpose because the upstream image does not cleanly support it, -with non-root, `drop: ["ALL"]` and `allowPrivilegeEscalation: false` as -compensating controls. Either those comments or this gate is wrong; resolving -that needs a cluster to test against, so it is open rather than decided. - -The `image` job `needs` this one, so build → SBOM → cosign does not run while -these stand. That is the honest state: the signing pipeline is wired and -unblocked at the action level, but it has not yet produced a Rekor entry. +| KSV-0014 | `deploy/k8s/infra/postgres/postgres.yaml` | **Fixed.** Read-only root plus emptyDirs for `/var/run/postgresql` and `/tmp`. The "image cannot run read-only" comment was wrong, and the Kyverno exception for postgres is gone. | +| KSV-0014 | `deploy/k8s/observability/grafana/grafana.yaml` | **Fixed.** Read-only root, `/tmp` emptyDir, `GF_LOG_MODE=console`. | +| KSV-0014, KSV-0118 (x2) | `deploy/llm/ollama.yaml` | **Fixed.** seccomp, drop ALL, no privilege escalation, read-only root. Still uid 0 until the PVC is chowned (see `deploy/llm/README.md`). | +| KSV-0014 | `deploy/backup/postgres-backup-cronjob.yaml` | **Accepted until 2027-01-31** (`.trivyignore.yaml`). The job installs tools at runtime and is broken for other reasons (below); a rewrite fixes both. | +| AWS-0164 (x3) | `deploy/terraform/modules/network/main.tf` | **Fixed.** `map_public_ip_on_launch = false`. NAT uses an EIP; load balancers do not need it. | +| AWS-0132 | `deploy/terraform/modules/state-backend/main.tf` | **Fixed.** SSE-KMS with a dedicated CMK. | +| KSV-0109 | `deploy/helm/fp-ai-gateway/templates/configmap.yaml` | **False positive, accepted until 2027-04-30.** Surfaced only once the charts rendered: the key `FP_TEAM_TOKEN_BUDGET` contains "TOKEN" but holds an integer budget. | + +None of the Kubernetes fixes has been run against a cluster yet. Verify postgres +(synced by the `fp-infra` Argo CD app), grafana and ollama after rollout. + +**Go dependency advisories (2026-10-24):** after this triage the same scan +still reported HIGH dependency CVEs in every Go module, and govulncheck failed +all 15 modules on the reachable subset. These were first left to Dependabot PR +#17, but #17 cleared neither gate. It stopped short on `x/net`, `x/crypto`, +`klauspost/compress` and `moby/go-archive`, and it did not touch `cli/`, +`gen/go` or `services/bff`. This branch bumps them instead, to the lowest +release that clears every advisory, so #17 is superseded: + +| Package | Advisories | Fixed in | Now | +|---|---|---|---| +| `golang.org/x/net` | GO-2026-6603, -6611, -6612, -6617; CVE-2026-46600, CVE-2026-78669 | 0.60.0 | 0.60.0 | +| `google.golang.org/grpc` | GO-2026-6348 (1.83.1), GO-2026-6443 (1.83.2 on the 1.83 line) | 1.83.2 | 1.83.2 | +| `golang.org/x/text` | GO-2026-6629, CVE-2026-56851 | 0.41.0 | 0.42.0, the floor `x/net` 0.60.0 requires | +| `golang.org/x/crypto` | GO-2026-6354, GO-2026-6355 (reachable from `pkg`), CVE-2026-56854 | 0.56.0 | 0.57.0, the floor `x/net` 0.60.0 requires | +| `go.opentelemetry.io/otel/sdk`, `.../exporters/otlp/otlptrace` | GO-2026-6505 | 1.45.0 | 1.45.0, with the rest of the otel v1 set | +| `github.com/klauspost/compress` | CVE-2026-63209 | 1.18.7 | 1.18.7 | +| `github.com/moby/go-archive` | GO-2026-6253, CVE-2026-17106 | 0.3.0 | 0.3.0 | + +The otel bump also moves the semconv import in `pkg/observability` to v1.43.0. +That is the schema otel 1.45's `resource.Default()` carries; the comment there +explains why the two must match. + +Also fixed on this branch rather than left to Dependabot: + +- the web findings: `axios` 1.20.0 and `follow-redirects` 1.16.1 (#10 only + reached axios 1.19.0, still vulnerable); +- the standard-library finding govulncheck reported in every module, + GO-2026-6809 (`crypto/x509`, Go 1.26.9). Go is pinned to **1.26.10** in + `go.work`, every `go.mod`, `GO_VERSION` in both workflows and the + `golang:1.26.10-alpine` builder digest in every Dockerfile. With + `GOTOOLCHAIN=local` (setup-go and the golang image both set it) an older + toolchain now refuses to build instead of silently compiling on an unpatched + standard library. + +**Known defect, not a scanner finding:** `postgres-backup-cronjob.yaml` cannot +succeed as written. It runs as uid 999 but `apk add`s packages and writes `mc` +to `/usr/local/bin`, downloads that binary at runtime without verification, +calls `gpg --passphrase` without `--pinentry-mode loopback`, and dumps database +names (`fp_features`, `fp_experiments`, `fp_notifications`) that +`scripts/init-postgres.sh` does not create. It also depends on `minio/mc`, +which is no longer on Docker Hub. It needs a rewrite (pinned image with the +tools baked in, no runtime downloads, read-only root), not a patch. + +## Accepted findings + +`.trivyignore.yaml` is the only allowlist, and both the filesystem and the +image scan read it. Each entry must have: + +- a `statement` that says why it is a false positive or an accepted risk, and + what would remove it; +- an `expired_at` date. After that date Trivy stops honouring the entry and the + gate fails again, which forces a fresh decision rather than a permanent mute; +- the narrowest scope possible (`paths`, or a specific package/CVE), never a + rule ID for the whole repo. ## Supply chain -On a `v*` tag the image job additionally: - -1. builds the service image from the repo root context (distroless runtime, no - shell, no package manager), -2. generates a CycloneDX SBOM with Syft and uploads it as a build artifact, -3. signs the image **by digest** with `cosign` in keyless mode, and -4. attaches the SBOM as a signed CycloneDX attestation. +Every image is `ghcr.io/abd-ulbasit/forgepoint/`, the name the Helm +charts, the Rollouts and the Kyverno allowlist use. The pipeline is split so +that nothing reaches the registry unscanned: + +1. **`image`** (every run, `contents: read` only): builds the service image from + the repo root context (distroless runtime, no shell, no package manager) into + a local tarball, scans **that tarball** with Trivy (HIGH/CRITICAL fail), and + generates a CycloneDX SBOM of it with Syft. It never pushes. +2. **`publish`** (pushes to `main` and `v*` tags only; the only job with + `packages: write` and `id-token: write`): refuses a `v*` tag whose commit is + not on `main`, checks the tarball's config digest against the one that was + scanned, pushes it, checks the registry's manifest still points at that + config, then signs the image **by digest** with `cosign` in keyless mode and + attaches the SBOM as a signed CycloneDX attestation. + +Pull requests build and scan but never push or sign. Every published image, +`main` builds included, is signed, so the cluster can require a signature on +any image from this repository. Keyless means there is no long-lived signing key to leak: cosign exchanges the workflow's GitHub OIDC token for a short-lived Fulcio certificate, and the @@ -87,17 +209,22 @@ To verify a published image: ```sh cosign verify \ - --certificate-identity-regexp '^https://github.com/abd-ulbasit/forgepoint/\.github/workflows/security\.yml@refs/tags/v' \ + --certificate-identity-regexp '^https://github\.com/abd-ulbasit/forgepoint/\.github/workflows/security\.yml@refs/(heads/main|tags/v.*)$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - ghcr.io/abd-ulbasit/forgepoint-@ + ghcr.io/abd-ulbasit/forgepoint/@ ``` +Use `@refs/tags/v.*$` instead to accept release builds only. + ## Keeping it that way Dependency drift is what put ten reachable CVEs in this repo in the first place, so the gates above are paired with [Dependabot](.github/dependabot.yml): weekly -updates for all 15 Go modules, the `web/` npm tree, and every pinned GitHub -Action. Actions are pinned by commit SHA with the version in a trailing comment — +updates for all 15 Go modules, the `web/` npm tree, every pinned GitHub Action, +the digest-pinned Docker base images and deploy images (`docker`, `helm`, +`docker-compose`), and the Terraform providers with their lock files. Database +majors (postgres, redis) are excluded: those are migrations, not bumps. +Actions are pinned by commit SHA with the version in a trailing comment — a moved tag cannot swap an action out from under a run, and Dependabot rewrites the SHA and the comment together so the pin stays a pin. diff --git a/cli/go.mod b/cli/go.mod index c5559e4..bb6d34f 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,25 +1,17 @@ module github.com/abd-ulbasit/forgepoint/cli -go 1.25.0 +go 1.26.10 require ( - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 0ff5c3a..d27c2c2 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,8 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -11,58 +10,25 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/deploy/argocd/README.md b/deploy/argocd/README.md index e12d652..2cbf85b 100644 --- a/deploy/argocd/README.md +++ b/deploy/argocd/README.md @@ -6,8 +6,10 @@ cluster to match Git and **self-heals** drift. This is the realization of [ADR-0002](../../docs/adr/0002-gitops-with-argocd.md) and Milestone **M6 (GitOps)**. > **This REPLACES the CI `helm upgrade` push step.** CI no longer deploys. -> CI's job ends at *build → scan → sign → push image → bump the image tag in Git*. -> ArgoCD does the deploying. No cluster credentials live in CI anymore. +> CI's job ends at *build → scan → sign → push image*. The planned last step, +> *bump the image tag in Git*, is **not implemented yet**: `image.tag` in every +> `apps/fp-*.yaml` is `""` (Chart.appVersion) and is changed by hand in a +> reviewed PR. ArgoCD does the deploying. No cluster credentials live in CI. `REPO_URL` placeholder used throughout: `https://github.com/abd-ulbasit/forgepoint.git` (swap for your fork in `project.yaml`, `root.yaml`, and each `apps/*.yaml`). @@ -18,9 +20,13 @@ cluster to match Git and **self-heals** drift. This is the realization of ``` deploy/argocd/ -├── project.yaml AppProject "forgepoint" — the guardrail (least privilege): -│ allowed source repos, destination namespaces (fp-*), -│ and allowed resource kinds. Denies cluster RBAC. +├── project.yaml Two AppProjects (the guardrails, least privilege): +│ forgepoint-bootstrap — root only: Application objects +│ in `argocd`, nothing else; +│ forgepoint — child apps: fp-* namespaces only, never +│ `argocd`, no RBAC kinds, no CRDs. +├── default-project.yaml The built-in `default` project emptied, so an app +│ naming `project: default` can deploy nothing. ├── root.yaml ROOT Application (app-of-apps). The ONE thing you apply by │ hand. Watches apps/ and manages the child Applications. ├── apps/ Child Applications (root reconciles this directory): @@ -34,7 +40,8 @@ deploy/argocd/ ``` operator (once) ArgoCD controller (forever) ────────────── ─────────────────────────── - kubectl apply project.yaml ─► AppProject enforces guardrails on every app + kubectl apply project.yaml ─► AppProjects enforce guardrails on every app + kubectl apply default-project.yaml ─► `default` project can deploy nothing kubectl apply root.yaml ─► root watches apps/ ──► creates child Apps │ ├─ fp-infra (wave -1) ─► fp-infra ns @@ -60,6 +67,7 @@ reverted state. No imperative `helm rollback`. # 1. Install ArgoCD into the argocd namespace (Helm or upstream manifests). # 2. Apply the guardrail, then the root. That's the entire bootstrap. kubectl apply -n argocd -f deploy/argocd/project.yaml +kubectl apply -n argocd -f deploy/argocd/default-project.yaml kubectl apply -n argocd -f deploy/argocd/root.yaml # Everything else (infra + every service) appears via the app-of-apps. ``` @@ -101,8 +109,10 @@ the diff per service is ~3 lines. (Once all 10 are truly uniform, an ArgoCD Two supported paths. **Git is always the trigger** — ArgoCD only ever reacts to a commit. -**1. CI bumps the tag in Git (default, used here).** -After build → scan → sign → push, CI rewrites the image tag in Git and pushes: +**1. CI bumps the tag in Git (the plan; NOT implemented yet).** +After build → scan → sign → push, CI would rewrite the image tag in Git and push. +Today the Security workflow stops at push + sign, and the tag is edited by hand +in a reviewed PR. The intended flow: ``` service code change @@ -182,27 +192,75 @@ and whose own `syncPolicy` is also manual, so even adding a prod app is reviewed --- -## Guardrails recap (`project.yaml`) +## Guardrails recap (`project.yaml`, `default-project.yaml`) -The AppProject is least privilege for the delivery layer itself: +The AppProjects are least privilege for the delivery layer itself: +- **Two projects.** `forgepoint-bootstrap` (root only) may create + `argoproj.io/Application` in `argocd` and nothing else. `forgepoint` (every + child app) may never write to `argocd`. Before the split, one project allowed + `argocd` as a destination *and* Secret/Role/RoleBinding, so any merged chart + could drop a cluster/repo credential Secret or a RoleBinding into Argo CD's + own namespace. - **sourceRepos** — only the forgepoint repo may be a source (supply-chain guard). -- **destinations** — only `fp-system / fp-infra / fp-models / fp-jobs` (+ `argocd` - for the child Application CRs). Nothing lands in `kube-system` / `default`. -- **clusterResourceWhitelist** — only `Namespace` and `CustomResourceDefinition`. - **No ClusterRole/ClusterRoleBinding** — a child app can't mint cluster-wide RBAC. -- **namespacedResourceWhitelist** — exactly the kinds the fp-auth chart + infra - kustomize render (Deployment, Service, ConfigMap, Secret, HPA, PDB, - NetworkPolicy, namespaced Role/RoleBinding, ServiceMonitor, ...). Deny-by-default: - add a kind here the day a chart needs it. +- **destinations** — `forgepoint`: only `fp-system / fp-infra / fp-models / fp-jobs`. + Nothing lands in `argocd` / `kube-system` / `default`. +- **clusterResourceWhitelist** — only `Namespace`. No CRDs (a CRD write can + redefine any API group) and **no ClusterRole/ClusterRoleBinding**. +- **namespaceResourceWhitelist** — exactly the kinds the charts + infra + kustomize render (Deployment, StatefulSet, Job, Service, ConfigMap, Secret, + ServiceAccount, PVC, HPA, PDB, NetworkPolicy, ServiceMonitor). No Role or + RoleBinding: namespaced RBAC from Git is a privilege grant. Deny-by-default: + add a kind here the day a chart needs it. Spell the key exactly + `namespaceResourceWhitelist`: Argo CD treats a missing namespaced whitelist + as "allow every kind", so a typo removes the guardrail instead of tightening it. +- **`default` project emptied** — an Application chooses its own `spec.project`, + so without this a child app could name `project: default` and bypass all of the + above. It has no repos, no destinations and a `*/*` namespaced blacklist (the + lock-down the Argo CD docs recommend; an empty whitelist alone is fragile). +- **Check before you trust it:** `kubectl apply --dry-run=server -f deploy/argocd/project.yaml -f deploy/argocd/default-project.yaml` + against a cluster with the Argo CD CRDs. kubectl's strict field validation + rejects a misspelled key. --- ## Secrets note GitOps means manifests live in Git — so plaintext secrets must NOT. The fp-auth -chart's in-chart Secret holds **dev-only placeholders** (see its values.yaml -SECURITY CONTRACT). In prod, `secrets.create: false` and the real Secret comes +chart's optional in-chart Secret holds **dev-only placeholders** (see its values.yaml +SECURITY CONTRACT), so `secrets.create` defaults to `false` and the real Secret comes from **External Secrets Operator** or **Sealed Secrets** (ADR-0002 follow-up, Phase 17) — the encrypted/sealed material is safe to commit, the plaintext never is. + +### Every app needs its Secret before the first sync + +`secrets.create` is `false` in every chart, and nothing the root app syncs +creates the Secrets. Each Deployment still mounts a Secret **named after its +release** (`envFrom`), so an app whose Secret does not exist yet still syncs, +but its pods sit in `CreateContainerConfigError` and the app goes Progressing, +then Degraded. That is deliberate +(fail closed instead of running on placeholder credentials), but plan for it: + +| App (Secret name) | Namespace | Keys the chart expects | Provided in this repo | +|---|---|---|---| +| `fp-auth` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET` (optional: `FP_BOOTSTRAP_ADMIN_PASSWORD`) | ExternalSecret `deploy/secrets/external-secrets/externalsecret-fp-auth.yaml` (applied by hand, not synced) | +| `fp-billing` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET` | **nothing yet** | +| `fp-experiment-tracker` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET` | **nothing yet** | +| `fp-feature-store` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET`, `FP_REDIS_URL` | **nothing yet** | +| `fp-inference-gateway` | fp-system | `FP_JWT_SECRET`, `FP_REDIS_URL` | **nothing yet** | +| `fp-model-monitor` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET`, `FP_REDIS_URL` | **nothing yet** | +| `fp-model-serving` | fp-models | `FP_JWT_SECRET` | **nothing yet** | +| `fp-notification` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET` | **nothing yet** | +| `fp-pipeline-orchestrator` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET` | **nothing yet** | +| `fp-registry` | fp-system | `FP_DATABASE_URL`, `FP_JWT_SECRET` | **nothing yet** | + +The key lists come from each chart's `templates/secret.yaml` (the dev Secret it +renders when `secrets.create=true`); that file is the reference if they drift. +`FP_JWT_SECRET` must be the same value in all ten. For the nine without one, +add an ExternalSecret (modelled on `externalsecret-fp-auth.yaml`, which also +shows how to template a DSN) or a SealedSecret per app, and apply it before +enabling the app. `fp-infra-creds` (`externalsecret-fp-infra-creds.yaml`) +carries the shared Postgres and Redis URLs, but no chart reads it by default. +For local Kind clusters, `make helm-install` passes `--set secrets.create=true` +with the dev placeholders, so this only bites GitOps installs. ``` diff --git a/deploy/argocd/apps/fp-auth.yaml b/deploy/argocd/apps/fp-auth.yaml index 8503646..1597a82 100644 --- a/deploy/argocd/apps/fp-auth.yaml +++ b/deploy/argocd/apps/fp-auth.yaml @@ -61,25 +61,27 @@ spec: releaseName: fp-auth # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-billing.yaml b/deploy/argocd/apps/fp-billing.yaml index f7ecd4f..28f6eda 100644 --- a/deploy/argocd/apps/fp-billing.yaml +++ b/deploy/argocd/apps/fp-billing.yaml @@ -61,25 +61,27 @@ spec: releaseName: fp-billing # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-experiment-tracker.yaml b/deploy/argocd/apps/fp-experiment-tracker.yaml index 59f4885..4546c0f 100644 --- a/deploy/argocd/apps/fp-experiment-tracker.yaml +++ b/deploy/argocd/apps/fp-experiment-tracker.yaml @@ -62,25 +62,27 @@ spec: releaseName: fp-experiment-tracker # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-feature-store.yaml b/deploy/argocd/apps/fp-feature-store.yaml index 3e60100..b581461 100644 --- a/deploy/argocd/apps/fp-feature-store.yaml +++ b/deploy/argocd/apps/fp-feature-store.yaml @@ -56,25 +56,27 @@ spec: releaseName: fp-feature-store # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-inference-gateway.yaml b/deploy/argocd/apps/fp-inference-gateway.yaml index 9193edd..bb2b8a8 100644 --- a/deploy/argocd/apps/fp-inference-gateway.yaml +++ b/deploy/argocd/apps/fp-inference-gateway.yaml @@ -68,25 +68,27 @@ spec: releaseName: fp-inference-gateway # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-model-monitor.yaml b/deploy/argocd/apps/fp-model-monitor.yaml index 80035b6..91c81c6 100644 --- a/deploy/argocd/apps/fp-model-monitor.yaml +++ b/deploy/argocd/apps/fp-model-monitor.yaml @@ -57,25 +57,27 @@ spec: releaseName: fp-model-monitor # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-model-serving.yaml b/deploy/argocd/apps/fp-model-serving.yaml index 976c346..fc93399 100644 --- a/deploy/argocd/apps/fp-model-serving.yaml +++ b/deploy/argocd/apps/fp-model-serving.yaml @@ -49,26 +49,28 @@ spec: releaseName: fp-model-serving # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas, the per-version model identity) — that file is the env # override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-notification.yaml b/deploy/argocd/apps/fp-notification.yaml index ea38a6d..21471d7 100644 --- a/deploy/argocd/apps/fp-notification.yaml +++ b/deploy/argocd/apps/fp-notification.yaml @@ -63,25 +63,27 @@ spec: releaseName: fp-notification # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-pipeline-orchestrator.yaml b/deploy/argocd/apps/fp-pipeline-orchestrator.yaml index f5b01a1..69ce455 100644 --- a/deploy/argocd/apps/fp-pipeline-orchestrator.yaml +++ b/deploy/argocd/apps/fp-pipeline-orchestrator.yaml @@ -65,25 +65,27 @@ spec: releaseName: fp-pipeline-orchestrator # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/apps/fp-registry.yaml b/deploy/argocd/apps/fp-registry.yaml index f8549b2..fe774db 100644 --- a/deploy/argocd/apps/fp-registry.yaml +++ b/deploy/argocd/apps/fp-registry.yaml @@ -59,25 +59,27 @@ spec: releaseName: fp-registry # valueFiles layered LEFT→RIGHT (later overrides earlier). The chart's # committed values.yaml carries DEV-safe defaults (per its SECURITY - # CONTRACT: in-chart Secret with placeholder creds, FP_ENV=local). For prod + # CONTRACT: secrets.create=false, so a Secret named after the release + # must already exist, see README "Secrets note"; FP_ENV=local). For prod # you ADD `- values-prod.yaml` here (External Secrets, real image tag, # higher replicas) — that file is the env override surface. valueFiles: - values.yaml # ---------------------------------------------------------------------- - # IMAGE TAG — the ONE value CI bumps in Git (see README "how images update"). - # The chart defaults image.tag to "" → falls back to Chart.appVersion. In a - # real GitOps flow, CI rewrites THIS line (or a values-.yaml) to the - # freshly built immutable tag (git SHA / semver), commits, and pushes. That - # Git commit is the deploy trigger — ArgoCD syncs the new tag. No cluster - # creds in CI; the only thing CI touches is Git. + # IMAGE TAG — the value a GitOps flow bumps in Git. The chart defaults + # image.tag to "" → falls back to Chart.appVersion. # - # Left empty here so the committed default (appVersion) renders; CI's job is - # to fill it. Never set ":latest" — immutable tags make rollbacks a revert. + # CURRENT STATE: NOTHING bumps it. ADR-0002 plans for CI to rewrite this + # line (or a values-.yaml) after build → scan → sign → push, but that + # step does not exist (an earlier comment here claimed "CI overwrites" it). + # Until it does, deploy a build by setting this to its published tag + # (the semver, e.g. "0.2.0", or "sha-<40-char commit>") in a reviewed PR. + # Images: ghcr.io/abd-ulbasit/forgepoint/, signed by the Security + # workflow. Never ":latest" — immutable tags make rollbacks a revert. # ---------------------------------------------------------------------- parameters: - name: image.tag - value: "" # CI overwrites with the built image's immutable tag + value: "" # empty = Chart.appVersion; set by hand until CI bumps it destination: server: https://kubernetes.default.svc diff --git a/deploy/argocd/default-project.yaml b/deploy/argocd/default-project.yaml new file mode 100644 index 0000000..3a1a186 --- /dev/null +++ b/deploy/argocd/default-project.yaml @@ -0,0 +1,38 @@ +# ============================================================================ +# The built-in `default` AppProject, locked to nothing +# ============================================================================ +# Argo CD creates a `default` project that allows any repo, any cluster, any +# namespace and any kind. The root app (project forgepoint-bootstrap) may create +# Application objects, and an Application chooses its own `spec.project`, so a +# child Application merged to main that says `project: default` would escape +# every guardrail in project.yaml. Emptying `default` closes that door: an +# Application in it can deploy nothing. +# +# This is the lock-down the Argo CD docs recommend ("The Default Project"): +# no repos, no destinations, and a `*/*` namespaced BLACKLIST. The blacklist is +# what actually denies namespaced kinds. An empty namespaceResourceWhitelist +# alone is fragile: the Go field is `omitempty`, so anything that round-trips +# the object through Argo CD (UI, `argocd proj` CLI) drops the empty list, and +# a missing namespaced whitelist means "allow every kind". +# +# Apply once, by hand, with the other projects (it is not under apps/, so the +# root app never syncs it): +# kubectl apply -n argocd -f deploy/argocd/default-project.yaml +# Check first that nothing else in the cluster relies on `default`: +# kubectl -n argocd get applications -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.project}{"\n"}{end}' +# ============================================================================ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: default + namespace: argocd +spec: + description: Locked down. Use the forgepoint or forgepoint-bootstrap project. + sourceRepos: [] + destinations: [] + # Cluster-scoped kinds: an empty (or missing) whitelist already denies all. + clusterResourceWhitelist: [] + # Namespaced kinds: deny every group and kind. + namespaceResourceBlacklist: + - group: "*" + kind: "*" diff --git a/deploy/argocd/project.yaml b/deploy/argocd/project.yaml index 5774ef7..b473f58 100644 --- a/deploy/argocd/project.yaml +++ b/deploy/argocd/project.yaml @@ -1,25 +1,74 @@ # ============================================================================ -# ARGOCD APPPROJECT — forgepoint +# ARGOCD APPPROJECTS — forgepoint-bootstrap (root app) + forgepoint (workloads) # ============================================================================ # An AppProject is ArgoCD's TENANCY + GUARDRAIL boundary. Every Application # belongs to exactly one project, and the project caps what those Applications # are ALLOWED to do, regardless of what their manifests ask for. Think of it as # an RBAC + admission-policy envelope around a set of apps. # -# WHY a dedicated project (not the built-in "default")? -# The "default" project is wide open: any repo, any cluster, any namespace, -# any resource kind. That is fine for a toy, dangerous for a platform. A -# compromised or fat-fingered Application in `default` could deploy a -# cluster-admin ClusterRoleBinding into kube-system. Scoping a project to -# "only this repo, only fp-* namespaces, only these kinds" is LEAST PRIVILEGE -# applied to the delivery layer itself. +# WHY TWO PROJECTS (this used to be one): +# The single "forgepoint" project allowed the `argocd` namespace as a +# destination (so the root app could create child Applications) AND +# whitelisted Secret, Role and RoleBinding. Together that meant ANY chart or +# manifest merged to main could write a Secret into `argocd` (e.g. a cluster +# or repository credential Secret with argocd.argoproj.io/secret-type) or a +# RoleBinding granting itself read on Argo CD's own secrets: privilege +# escalation through Git, auto-synced. It also never whitelisted the +# Application kind, which the root app-of-apps exists to create. # -# HOW GitOps IS STOPPED FROM BECOMING A CLUSTER-ADMIN BACKDOOR: this object — +# Now: +# - forgepoint-bootstrap (root.yaml only): may create ONE kind, +# argoproj.io/Application, and only in `argocd`. Nothing else. +# - forgepoint (every child app): fp-* namespaces only, never `argocd`, no +# RBAC kinds, no CRDs. +# - default: locked to nothing (default-project.yaml), so a child Application +# that names `project: default` cannot escape both of the above. +# +# HOW GitOps IS STOPPED FROM BECOMING A CLUSTER-ADMIN BACKDOOR: these objects — # the sourceRepos allow-list, the destinations allow-list, and the -# namespaced/clusterResource allow-lists below. +# namespaced/clusterResource allow-lists below. They are applied by hand +# (kubectl), never synced by the root app, so a Git change cannot widen them. +# +# SPELLING MATTERS: the field is `namespaceResourceWhitelist` (no "d"). Argo CD +# treats a MISSING namespaced whitelist as "every namespaced kind is allowed", +# so a misspelled key is not just ignored, it silently removes the guardrail +# (if it gets past kubectl's strict field validation at all). Validate with +# kubectl apply --dry-run=server -f deploy/argocd/project.yaml +# against a cluster that has the Argo CD CRDs before relying on these limits. # ============================================================================ apiVersion: argoproj.io/v1alpha1 kind: AppProject +metadata: + name: forgepoint-bootstrap + namespace: argocd + labels: + app.kubernetes.io/part-of: forgepoint +spec: + description: Root app-of-apps only — may create Application objects in argocd, nothing else. + + sourceRepos: + - https://github.com/abd-ulbasit/forgepoint.git + + # The child Application CRs live in the namespace Argo CD runs in. + destinations: + - server: https://kubernetes.default.svc + namespace: argocd + + # No cluster-scoped kinds at all. + clusterResourceWhitelist: [] + + # Exactly one kind. In particular NOT Secret (Argo CD reads cluster and repo + # credentials from Secrets in this namespace) and NOT AppProject (a project + # that could edit projects could lift its own limits). + namespaceResourceWhitelist: + - group: argoproj.io + kind: Application + + orphanedResources: + warn: true +--- +apiVersion: argoproj.io/v1alpha1 +kind: AppProject metadata: name: forgepoint # AppProjects live in the namespace ArgoCD itself runs in (conventionally @@ -37,26 +86,19 @@ spec: # -------------------------------------------------------------------------- # sourceRepos — WHICH Git repos Applications in this project may pull from. # A wildcard "*" would let anyone point an app at an arbitrary repo (supply- - # chain risk). We pin to this one mono-repo. Add more entries only when a - # second trusted repo genuinely exists (e.g. a separate Helm chart registry). - # - # REPO_URL placeholder: this is THE forgepoint repo. Keep it generic; no creds - # belong here. For a private repo, ArgoCD reads credentials from a separately - # managed `repo-creds` Secret (label argocd.argoproj.io/secret-type=repository) - # — never inline a token in this manifest. + # chain risk). We pin to this one mono-repo. For a private repo, ArgoCD reads + # credentials from a separately managed `repo-creds` Secret (label + # argocd.argoproj.io/secret-type=repository) — never inline a token here. # -------------------------------------------------------------------------- sourceRepos: - https://github.com/abd-ulbasit/forgepoint.git # -------------------------------------------------------------------------- # destinations — WHICH (cluster, namespace) pairs apps may deploy into. - # We allow only the four Forgepoint namespaces, all on the in-cluster server - # (https://kubernetes.default.svc = the cluster ArgoCD runs in). Anything - # outside fp-* (kube-system, argocd, default) is denied even if a child app - # asks for it. This is the namespace half of least privilege. - # - # Multi-cluster later: to deploy prod to a remote cluster, add a destination - # with that cluster's API server URL (registered as an ArgoCD cluster Secret). + # Only the four Forgepoint namespaces on the in-cluster server. `argocd`, + # kube-system and default are denied even if a child app asks for them. + # (`argocd` used to be listed here for the root app; it now has its own + # project, forgepoint-bootstrap.) # -------------------------------------------------------------------------- destinations: - server: https://kubernetes.default.svc @@ -67,45 +109,36 @@ spec: namespace: fp-models - server: https://kubernetes.default.svc namespace: fp-jobs - # The root app-of-apps creates CHILD Applications, which are themselves - # objects living in the `argocd` namespace. Allow that one namespace so the - # project can manage its own Application CRs (app-of-apps needs this). - - server: https://kubernetes.default.svc - namespace: argocd # -------------------------------------------------------------------------- # clusterResourceWhitelist — CLUSTER-SCOPED kinds apps may create. - # Cluster-scoped resources have no namespace, so destinations can't gate them; - # this list is the only guard. We allow exactly two: - # Namespace — infra creates fp-infra/fp-system/fp-models/fp-jobs. - # CustomResourceDefinition — reserved for future operators we install via - # GitOps (e.g. Prometheus Operator, Argo Rollouts). - # NOTE the conspicuous ABSENCE of ClusterRole / ClusterRoleBinding here: no - # child app may mint cluster-wide RBAC. That is the single most important - # privilege to withhold from a delivery controller. + # Only Namespace (infra creates fp-infra/fp-system/fp-models/fp-jobs). + # CustomResourceDefinition was listed "for future operators"; nothing creates + # one, and a CRD write can replace the schema of ANY API group in the + # cluster, so it is removed until an operator genuinely needs it (and then + # belongs in its own project). No ClusterRole / ClusterRoleBinding, ever. # -------------------------------------------------------------------------- clusterResourceWhitelist: - group: "" kind: Namespace - - group: apiextensions.k8s.io - kind: CustomResourceDefinition # -------------------------------------------------------------------------- - # namespacedResourceWhitelist — NAMESPACED kinds apps may create. - # This is an ALLOW-list (deny-by-default): if a kind isn't named here, a child - # app cannot create it. It covers exactly what the fp-auth reference chart and - # the infra kustomize render today: - # workloads Deployment, StatefulSet, Pod, Service, ConfigMap, Secret, - # ServiceAccount, PersistentVolumeClaim + # namespaceResourceWhitelist — NAMESPACED kinds apps may create. + # Deny-by-default. Exactly what the fp-* charts and deploy/k8s/infra render + # today (checked with `helm template` / the infra manifests): + # workloads Deployment, StatefulSet, Job (the MinIO bucket init), + # Service, ConfigMap, Secret, ServiceAccount, + # PersistentVolumeClaim # scaling/avail HorizontalPodAutoscaler, PodDisruptionBudget - # security NetworkPolicy, Role, RoleBinding (namespaced RBAC is OK; - # cluster RBAC is not — see whitelist above) + # network NetworkPolicy # operator CRs ServiceMonitor (Prometheus Operator) - # Add a kind here the day a chart needs it (e.g. Rollout, Ingress, KEDA - # ScaledObject) — a missing entry yields a clear "resource not permitted in - # project" sync error rather than a silent deploy of something unexpected. + # Role/RoleBinding were whitelisted but nothing renders them; namespaced RBAC + # written from Git is a privilege grant, so add it back only with a reason. + # batch/Job was MISSING although the infra kustomization renders one, so the + # fp-infra sync could not have succeeded. Secret stays (infra placeholders and + # the charts' optional dev Secret), but only in fp-* namespaces now. # -------------------------------------------------------------------------- - namespacedResourceWhitelist: + namespaceResourceWhitelist: - group: "" kind: ConfigMap - group: "" @@ -120,24 +153,20 @@ spec: kind: Deployment - group: apps kind: StatefulSet + - group: batch + kind: Job - group: autoscaling kind: HorizontalPodAutoscaler - group: policy kind: PodDisruptionBudget - group: networking.k8s.io kind: NetworkPolicy - - group: rbac.authorization.k8s.io - kind: Role - - group: rbac.authorization.k8s.io - kind: RoleBinding - group: monitoring.coreos.com kind: ServiceMonitor # -------------------------------------------------------------------------- # orphanedResources — surface (don't auto-delete) resources that exist in a - # project namespace but aren't tracked by any Application. warn:true flags them - # in the UI as a hygiene signal without taking destructive action. We keep this - # informational; prune (below, per-app) handles deletion of MANAGED resources. + # project namespace but aren't tracked by any Application. # -------------------------------------------------------------------------- orphanedResources: warn: true diff --git a/deploy/argocd/root.yaml b/deploy/argocd/root.yaml index 20f08d8..eef82ac 100644 --- a/deploy/argocd/root.yaml +++ b/deploy/argocd/root.yaml @@ -51,9 +51,12 @@ metadata: labels: app.kubernetes.io/part-of: forgepoint spec: - # Bind to the guardrail project from project.yaml. The root only manages - # Application CRs in the `argocd` namespace, which the project permits. - project: forgepoint + # Bind to the bootstrap project from project.yaml. It permits exactly one + # thing: Application objects in the `argocd` namespace. The child apps it + # creates run under the separate `forgepoint` project, which cannot touch + # `argocd` at all. (The root used to share that project, which is why the + # workload project had to allow writes into `argocd`.) + project: forgepoint-bootstrap source: # REPO_URL — the one forgepoint mono-repo (must match an entry in the diff --git a/deploy/backup/postgres-backup-cronjob.yaml b/deploy/backup/postgres-backup-cronjob.yaml index 98ad2d5..c4a6c5b 100644 --- a/deploy/backup/postgres-backup-cronjob.yaml +++ b/deploy/backup/postgres-backup-cronjob.yaml @@ -135,8 +135,9 @@ spec: containers: - name: pg-dump # Use the same Postgres version as the server to avoid pg_dump - # client/server version skew warnings. Pin the tag. - image: postgres:17-alpine + # client/server version skew warnings. Pinned by tag AND digest (Docker + # Hub tags can be re-pushed; the digest is what pins the bytes). + image: postgres:17.11-alpine@sha256:eee6c6e1b0894fa4fe0075eb054af9fd1cf39a35321c2d4110626cfe506411fb imagePullPolicy: IfNotPresent securityContext: diff --git a/deploy/helm/fp-ai-gateway/templates/_helpers.tpl b/deploy/helm/fp-ai-gateway/templates/_helpers.tpl index 7998c9f..1b13acd 100644 --- a/deploy/helm/fp-ai-gateway/templates/_helpers.tpl +++ b/deploy/helm/fp-ai-gateway/templates/_helpers.tpl @@ -109,7 +109,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-ai-gateway.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-ai-gateway.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-ai-gateway/templates/deployment.yaml b/deploy/helm/fp-ai-gateway/templates/deployment.yaml index 6f22081..2f21914 100644 --- a/deploy/helm/fp-ai-gateway/templates/deployment.yaml +++ b/deploy/helm/fp-ai-gateway/templates/deployment.yaml @@ -110,7 +110,7 @@ spec: IDEMPOTENCY: golang-migrate tracks the applied version in schema_migrations. A re-deploy with no new files prints "no change" and exits 0. */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -226,7 +226,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-ai-gateway/values.yaml b/deploy/helm/fp-ai-gateway/values.yaml index b0e4d9d..c45b312 100644 --- a/deploy/helm/fp-ai-gateway/values.yaml +++ b/deploy/helm/fp-ai-gateway/values.yaml @@ -163,9 +163,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # The gateway has TWO sensitive values: # - redisUrl (FP_REDIS_URL): the DSN may embed a password / TLS auth. @@ -178,7 +178,12 @@ config: # a signing key is access-controlled, not plaintext config. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -236,9 +241,11 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the `migrate` - # binary on PATH. Pinned to an immutable tag for reproducible rollouts. + # binary on PATH. Pinned BY DIGEST: Docker Hub tags are mutable (a re-pushed + # v4.18.3 would change what the kubelet pulls), so the tag is for humans and + # the digest is what actually gets pulled. Bump both together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. resources: diff --git a/deploy/helm/fp-auth/templates/_helpers.tpl b/deploy/helm/fp-auth/templates/_helpers.tpl index 69abff6..1948178 100644 --- a/deploy/helm/fp-auth/templates/_helpers.tpl +++ b/deploy/helm/fp-auth/templates/_helpers.tpl @@ -104,7 +104,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-auth.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-auth.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-auth/templates/deployment.yaml b/deploy/helm/fp-auth/templates/deployment.yaml index 9979a93..db987f5 100644 --- a/deploy/helm/fp-auth/templates/deployment.yaml +++ b/deploy/helm/fp-auth/templates/deployment.yaml @@ -104,7 +104,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 — safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -218,7 +218,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only — the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-auth/values.yaml b/deploy/helm/fp-auth/values.yaml index 46c888e..166cfe1 100644 --- a/deploy/helm/fp-auth/values.yaml +++ b/deploy/helm/fp-auth/values.yaml @@ -92,12 +92,17 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -144,10 +149,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/helm/fp-billing/templates/_helpers.tpl b/deploy/helm/fp-billing/templates/_helpers.tpl index 7a8416e..57d34ba 100644 --- a/deploy/helm/fp-billing/templates/_helpers.tpl +++ b/deploy/helm/fp-billing/templates/_helpers.tpl @@ -104,7 +104,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-billing.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-billing.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-billing/templates/deployment.yaml b/deploy/helm/fp-billing/templates/deployment.yaml index 33ff410..4f63420 100644 --- a/deploy/helm/fp-billing/templates/deployment.yaml +++ b/deploy/helm/fp-billing/templates/deployment.yaml @@ -105,7 +105,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 -- safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -220,7 +220,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-billing/values.yaml b/deploy/helm/fp-billing/values.yaml index a5dfe73..6052e08 100644 --- a/deploy/helm/fp-billing/values.yaml +++ b/deploy/helm/fp-billing/values.yaml @@ -112,16 +112,21 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # Billing has TWO secrets: the Postgres DSN and the JWT VERIFY key (FP_JWT_SECRET, # the shared HMAC key the auth interceptor uses to verify token signatures — see # the header). Neither is a JWT SIGNING key; billing never mints tokens. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -170,10 +175,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/helm/fp-experiment-tracker/templates/_helpers.tpl b/deploy/helm/fp-experiment-tracker/templates/_helpers.tpl index 5475f49..70c6578 100644 --- a/deploy/helm/fp-experiment-tracker/templates/_helpers.tpl +++ b/deploy/helm/fp-experiment-tracker/templates/_helpers.tpl @@ -106,7 +106,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-experiment-tracker.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-experiment-tracker.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-experiment-tracker/templates/deployment.yaml b/deploy/helm/fp-experiment-tracker/templates/deployment.yaml index 0633412..93c0469 100644 --- a/deploy/helm/fp-experiment-tracker/templates/deployment.yaml +++ b/deploy/helm/fp-experiment-tracker/templates/deployment.yaml @@ -106,7 +106,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 -- safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -220,7 +220,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-experiment-tracker/values.yaml b/deploy/helm/fp-experiment-tracker/values.yaml index 824edd9..54673c7 100644 --- a/deploy/helm/fp-experiment-tracker/values.yaml +++ b/deploy/helm/fp-experiment-tracker/values.yaml @@ -104,9 +104,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # This service does NOT MINT JWTs (auth is the sole minter), but it MUST VERIFY # caller JWTs in-process for its auth interceptor (design D2 — local HMAC verify, @@ -116,7 +116,12 @@ config: # JWT signing key and its own Postgres DSN. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -164,10 +169,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/helm/fp-feature-store/templates/_helpers.tpl b/deploy/helm/fp-feature-store/templates/_helpers.tpl index 2b9029d..161026f 100644 --- a/deploy/helm/fp-feature-store/templates/_helpers.tpl +++ b/deploy/helm/fp-feature-store/templates/_helpers.tpl @@ -107,7 +107,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-feature-store.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-feature-store.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-feature-store/templates/deployment.yaml b/deploy/helm/fp-feature-store/templates/deployment.yaml index 03a3359..b17320c 100644 --- a/deploy/helm/fp-feature-store/templates/deployment.yaml +++ b/deploy/helm/fp-feature-store/templates/deployment.yaml @@ -104,7 +104,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 -- safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -218,7 +218,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-feature-store/values.yaml b/deploy/helm/fp-feature-store/values.yaml index 65193da..16fbbde 100644 --- a/deploy/helm/fp-feature-store/values.yaml +++ b/deploy/helm/fp-feature-store/values.yaml @@ -94,9 +94,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # NOTE vs fp-auth: feature-store does NOT MINT tokens (auth does) — but it must # VERIFY them. The shared authentication interceptor verifies each caller's JWT @@ -109,7 +109,12 @@ config: # FP_JWT_SECRET from a managed store exactly like FP_DATABASE_URL. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -161,10 +166,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/helm/fp-inference-gateway/templates/_helpers.tpl b/deploy/helm/fp-inference-gateway/templates/_helpers.tpl index 5cf73bf..6cdeb54 100644 --- a/deploy/helm/fp-inference-gateway/templates/_helpers.tpl +++ b/deploy/helm/fp-inference-gateway/templates/_helpers.tpl @@ -109,7 +109,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-inference-gateway.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-inference-gateway.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-inference-gateway/values.yaml b/deploy/helm/fp-inference-gateway/values.yaml index df50b5f..6d765c1 100644 --- a/deploy/helm/fp-inference-gateway/values.yaml +++ b/deploy/helm/fp-inference-gateway/values.yaml @@ -125,9 +125,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # The gateway has TWO sensitive values: # - redisUrl (FP_REDIS_URL): the DSN may embed a password / TLS auth. @@ -140,7 +140,12 @@ config: # a signing key is access-controlled, not plaintext config. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" diff --git a/deploy/helm/fp-model-monitor/templates/_helpers.tpl b/deploy/helm/fp-model-monitor/templates/_helpers.tpl index 2cb754b..a6aef4c 100644 --- a/deploy/helm/fp-model-monitor/templates/_helpers.tpl +++ b/deploy/helm/fp-model-monitor/templates/_helpers.tpl @@ -106,7 +106,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-model-monitor.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-model-monitor.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-model-monitor/templates/deployment.yaml b/deploy/helm/fp-model-monitor/templates/deployment.yaml index 4cf48e9..168b0e2 100644 --- a/deploy/helm/fp-model-monitor/templates/deployment.yaml +++ b/deploy/helm/fp-model-monitor/templates/deployment.yaml @@ -104,7 +104,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 -- safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -219,7 +219,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-model-monitor/values.yaml b/deploy/helm/fp-model-monitor/values.yaml index 259ad39..249ec82 100644 --- a/deploy/helm/fp-model-monitor/values.yaml +++ b/deploy/helm/fp-model-monitor/values.yaml @@ -154,9 +154,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # NOTE vs fp-auth: model-monitor does NOT mint tokens, but it MUST verify them — so # it DOES carry a jwtSecret (the shared auth interceptor's HS256 verify key, design @@ -164,7 +164,12 @@ config: # the Postgres DSN and the Redis URL (both may embed creds). # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -215,10 +220,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/helm/fp-model-serving/templates/_helpers.tpl b/deploy/helm/fp-model-serving/templates/_helpers.tpl index e9c3f80..cc74e8c 100644 --- a/deploy/helm/fp-model-serving/templates/_helpers.tpl +++ b/deploy/helm/fp-model-serving/templates/_helpers.tpl @@ -109,7 +109,7 @@ DSN or JWT key (this service has neither). {{- if .Values.secrets.create -}} {{- include "fp-model-serving.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-model-serving.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-model-serving/values.yaml b/deploy/helm/fp-model-serving/values.yaml index 17ae9f3..7af2e5a 100644 --- a/deploy/helm/fp-model-serving/values.yaml +++ b/deploy/helm/fp-model-serving/values.yaml @@ -141,9 +141,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # Unlike fp-auth (which also has a DB DSN), model-serving has no database. It # DOES share fp-auth's JWT signing key (the gRPC auth interceptor verifies every @@ -152,7 +152,12 @@ config: # pull the artifact. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" diff --git a/deploy/helm/fp-notification/templates/_helpers.tpl b/deploy/helm/fp-notification/templates/_helpers.tpl index bcbfdb7..6f372b1 100644 --- a/deploy/helm/fp-notification/templates/_helpers.tpl +++ b/deploy/helm/fp-notification/templates/_helpers.tpl @@ -105,7 +105,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-notification.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-notification.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-notification/templates/deployment.yaml b/deploy/helm/fp-notification/templates/deployment.yaml index 49d7ea7..81bf4c0 100644 --- a/deploy/helm/fp-notification/templates/deployment.yaml +++ b/deploy/helm/fp-notification/templates/deployment.yaml @@ -104,7 +104,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 -- safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -218,7 +218,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-notification/values.yaml b/deploy/helm/fp-notification/values.yaml index 03a548a..0a1091d 100644 --- a/deploy/helm/fp-notification/values.yaml +++ b/deploy/helm/fp-notification/values.yaml @@ -111,9 +111,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # WHY a jwtSecret here even though notification does NOT mint tokens: the gRPC auth # interceptor VERIFIES every caller's JWT locally (design D2 — no per-request hop to @@ -124,7 +124,12 @@ config: # (NOT the ConfigMap) because it is sensitive symmetric key material. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -170,10 +175,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/helm/fp-pipeline-orchestrator/templates/_helpers.tpl b/deploy/helm/fp-pipeline-orchestrator/templates/_helpers.tpl index 6055c70..0560355 100644 --- a/deploy/helm/fp-pipeline-orchestrator/templates/_helpers.tpl +++ b/deploy/helm/fp-pipeline-orchestrator/templates/_helpers.tpl @@ -105,7 +105,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-pipeline-orchestrator.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-pipeline-orchestrator.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-pipeline-orchestrator/templates/deployment.yaml b/deploy/helm/fp-pipeline-orchestrator/templates/deployment.yaml index 91c7bb7..e008cae 100644 --- a/deploy/helm/fp-pipeline-orchestrator/templates/deployment.yaml +++ b/deploy/helm/fp-pipeline-orchestrator/templates/deployment.yaml @@ -106,7 +106,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 -- safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -220,7 +220,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-pipeline-orchestrator/values.yaml b/deploy/helm/fp-pipeline-orchestrator/values.yaml index 845e7ea..f6f90db 100644 --- a/deploy/helm/fp-pipeline-orchestrator/values.yaml +++ b/deploy/helm/fp-pipeline-orchestrator/values.yaml @@ -95,9 +95,9 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # # NOTE vs fp-auth: the orchestrator does not MINT tokens (only fp-auth holds the # authority to issue them), but it DOES need the shared HMAC key to VERIFY inbound @@ -105,7 +105,12 @@ config: # the SAME jwtSecret as fp-auth, plus its own Postgres DSN. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -151,10 +156,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/helm/fp-registry/templates/_helpers.tpl b/deploy/helm/fp-registry/templates/_helpers.tpl index c78aa95..06eb2f2 100644 --- a/deploy/helm/fp-registry/templates/_helpers.tpl +++ b/deploy/helm/fp-registry/templates/_helpers.tpl @@ -105,7 +105,7 @@ secrets.existingSecret. Either way the Deployment references ONE stable name. {{- if .Values.secrets.create -}} {{- include "fp-registry.fullname" . -}} {{- else -}} -{{- required "secrets.create=false requires secrets.existingSecret to name the externally-managed Secret" .Values.secrets.existingSecret -}} +{{- default (include "fp-registry.fullname" .) .Values.secrets.existingSecret -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/fp-registry/templates/deployment.yaml b/deploy/helm/fp-registry/templates/deployment.yaml index 359514b..5467b53 100644 --- a/deploy/helm/fp-registry/templates/deployment.yaml +++ b/deploy/helm/fp-registry/templates/deployment.yaml @@ -104,7 +104,7 @@ spec: A re-deploy with no new files prints "no change" and exits 0 -- safe to run on every rollout and every replica (it serializes on the DB). */}} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} initContainers: - name: migrate image: "{{ .Values.migrations.image.repository }}:{{ .Values.migrations.image.tag }}" @@ -219,7 +219,7 @@ spec: volumes: - name: tmp emptyDir: {} - {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (and (not .Values.secrets.create) .Values.secrets.existingSecret)) }} + {{- if and .Values.migrations.enabled (or (and .Values.secrets.create .Values.secrets.databaseUrl) (not .Values.secrets.create)) }} # The migrations ConfigMap (the chart's migrations/*.sql) mounted into the # migrate initContainer at /migrations. defaultMode 0444 = world-readable, # read-only -- the nonroot migrate process can read it without owning it. diff --git a/deploy/helm/fp-registry/values.yaml b/deploy/helm/fp-registry/values.yaml index 440e67e..fcdbcfd 100644 --- a/deploy/helm/fp-registry/values.yaml +++ b/deploy/helm/fp-registry/values.yaml @@ -111,16 +111,21 @@ config: # below. Convenient for local Kind; NEVER commit real # values here. # secrets.create: false → chart renders NO Secret. You must provide a Secret -# named `secrets.existingSecret` out-of-band (External -# Secrets / Sealed Secrets / kubectl). The Deployment -# still envFrom's it. +# named `secrets.existingSecret` (default: the chart +# fullname) out-of-band (External Secrets / Sealed +# Secrets / kubectl). The Deployment still envFrom's it. # Registry carries TWO secrets: the Postgres DSN (password is the sensitive part) # and the shared jwtSecret it uses to VERIFY inbound JWTs (design D2 local verify — # it does NOT sign tokens; that's auth's job). The jwtSecret MUST equal auth's # FP_JWT_SECRET byte-for-byte, or tokens auth mints fail verification here. # ---------------------------------------------------------------------------- secrets: - create: true + # DEFAULT false (fail-closed). The chart renders NO Secret and the Deployment + # envFrom's an externally-managed one (External Secrets Operator / Sealed + # Secrets). If that Secret is missing the pod fails to start, instead of + # silently running with the committed dev placeholders below. Local Kind: + # `make helm-install` passes --set secrets.create=true. + create: false # When create=false, the Deployment envFrom's this externally-managed Secret. # Defaults to the chart's fullname when empty (see _helpers "secretName"). existingSecret: "" @@ -166,10 +171,12 @@ migrations: image: # golang-migrate official image. Alpine-based, so it has /bin/sh (needed to # expand the DSN from env without baking it into the pod spec) and the - # `migrate` binary on PATH. Pinned to an immutable tag for reproducible - # rollouts — bump deliberately, never track a moving tag. + # `migrate` binary on PATH. Pinned BY DIGEST: Docker Hub tags are + # mutable (a re-pushed v4.18.3 would change what the kubelet pulls), so the + # tag is for humans and the digest is what actually gets pulled. Bump both + # together, deliberately. repository: migrate/migrate - tag: v4.18.3 + tag: v4.18.3@sha256:39b59b389634e43bb3f2d4e94bc1edef0775ec2a9a3540ce6a2cf330e5daae55 pullPolicy: IfNotPresent # Modest resources: the migrate step is short-lived and IO-light. Requests are # tiny so it never blocks scheduling; limits cap a pathological migration. diff --git a/deploy/k8s/infra/nats/nats.yaml b/deploy/k8s/infra/nats/nats.yaml index 97f82f2..ea16df8 100644 --- a/deploy/k8s/infra/nats/nats.yaml +++ b/deploy/k8s/infra/nats/nats.yaml @@ -109,7 +109,9 @@ spec: type: RuntimeDefault # use the kernel's default seccomp filter containers: - name: nats - image: nats:2.11 + # Tag for humans, digest for the kubelet: Docker Hub tags (even version tags) + # can be re-pushed, so the digest is what pins the bytes. Bump both together. + image: nats:2.11.17@sha256:c991221cdddd53a24893cd18a90faf7a42396108496aed076bb982a349e76484 args: ["-c", "/etc/nats/nats.conf"] ports: - name: client diff --git a/deploy/k8s/infra/postgres/postgres.yaml b/deploy/k8s/infra/postgres/postgres.yaml index 7f2bf43..4827ef2 100644 --- a/deploy/k8s/infra/postgres/postgres.yaml +++ b/deploy/k8s/infra/postgres/postgres.yaml @@ -127,7 +127,9 @@ spec: type: RuntimeDefault containers: - name: postgres - image: postgres:17 + # Tag for humans, digest for the kubelet: Docker Hub tags (even version tags) + # can be re-pushed, so the digest is what pins the bytes. Bump both together. + image: postgres:17.11@sha256:b8faeff7712663c2773a190181e1246e37e364b887611c04658c28b44e2408fe envFrom: - secretRef: name: postgres-credentials @@ -143,10 +145,16 @@ spec: containerPort: 5432 securityContext: allowPrivilegeEscalation: false - # readOnlyRootFilesystem is NOT set: the postgres entrypoint writes to - # /var/run/postgresql (socket) and a few transient paths. We give it a - # writable emptyDir for the socket dir instead of a fully-read-only FS, - # which the image doesn't cleanly support. Data still lives on the PVC. + # Read-only root FS (Trivy KSV-0014). Running as uid 999 the entrypoint + # skips its root-only chown/gosu step, and everything it and the server + # write lands on one of three mounts: PGDATA on the PVC, the unix socket + # in /var/run/postgresql, and /tmp (bash heredoc spill files, nss_wrapper + # if the uid were ever not in /etc/passwd). Both of the latter are + # emptyDirs below. Earlier revisions left this unset on the belief the + # image could not run read-only; it can, as long as those paths are + # writable. VERIFY on the cluster after rollout: `kubectl -n fp-infra + # logs postgres-0` should show "database system is ready". + readOnlyRootFilesystem: true capabilities: drop: ["ALL"] resources: @@ -184,6 +192,8 @@ spec: readOnly: true - name: run mountPath: /var/run/postgresql + - name: tmp + mountPath: /tmp volumes: - name: init configMap: @@ -195,6 +205,9 @@ spec: # triggers parse errors in strict YAML-1.1 tooling (e.g. some linters). - name: run emptyDir: {} + - name: tmp + emptyDir: + sizeLimit: 256Mi volumeClaimTemplates: - metadata: name: data diff --git a/deploy/k8s/infra/redis/redis.yaml b/deploy/k8s/infra/redis/redis.yaml index ae98a3b..9c90be4 100644 --- a/deploy/k8s/infra/redis/redis.yaml +++ b/deploy/k8s/infra/redis/redis.yaml @@ -101,7 +101,9 @@ spec: type: RuntimeDefault containers: - name: redis - image: redis:7 + # Tag for humans, digest for the kubelet: Docker Hub tags (even version tags) + # can be re-pushed, so the digest is what pins the bytes. Bump both together. + image: redis:7.4.11@sha256:44960b3100c48159a66d28c18b0588c193492e25f7551d60e51dd464fc3eca5b args: ["redis-server", "/etc/redis/redis.conf"] ports: - name: redis diff --git a/deploy/k8s/observability/grafana/grafana.yaml b/deploy/k8s/observability/grafana/grafana.yaml index efe8653..59d74fb 100644 --- a/deploy/k8s/observability/grafana/grafana.yaml +++ b/deploy/k8s/observability/grafana/grafana.yaml @@ -260,7 +260,7 @@ spec: # VERIFY before first deploy: confirm this tag on Docker Hub # (hub.docker.com/r/grafana/grafana/tags) and check the release notes # for provisioning-format or datasource-API breaking changes. - image: grafana/grafana:11.5.1 + image: grafana/grafana:11.5.1@sha256:5781759b3d27734d4d548fcbaf60b1180dbf4290e708f01f292faa6ae764c5e6 env: # ---- SECURITY HARDENING (same as docker-compose) ---------------- # Admin password injected from the Secret — NOT hardcoded, NOT the @@ -289,15 +289,23 @@ spec: # Provisioned dashboards live under this path (matches the provider). - name: GF_PATHS_PROVISIONING value: /etc/grafana/provisioning + # Log to stdout only. The image default also writes + # /var/log/grafana/grafana.log, which a read-only root FS forbids, and + # the kubelet already captures stdout. + - name: GF_LOG_MODE + value: console ports: - name: http containerPort: 3000 securityContext: allowPrivilegeEscalation: false - # NOT read-only: Grafana writes its internal SQLite + plugin cache to - # /var/lib/grafana even in provisioned mode. We give it a writable - # emptyDir there (below) rather than a fully read-only root FS, which - # the image does not cleanly support. + # Read-only root FS (Trivy KSV-0014). Grafana's writes all have a + # writable mount: SQLite + plugins in /var/lib/grafana (emptyDir), + # scratch in /tmp (emptyDir), and logs go to stdout (GF_LOG_MODE + # above). The earlier note said the image does not cleanly support + # this; with those three paths covered it should. VERIFY after apply: + # /api/health returns 200 and the provisioned dashboards load. + readOnlyRootFilesystem: true capabilities: drop: ["ALL"] resources: @@ -340,6 +348,9 @@ spec: # Writable scratch for Grafana's runtime state (SQLite, plugins). - name: grafana-data mountPath: /var/lib/grafana + # Writable scratch (session/render temp files) for the read-only root. + - name: tmp + mountPath: /tmp volumes: - name: datasources configMap: @@ -352,3 +363,6 @@ spec: name: grafana-dashboards - name: grafana-data emptyDir: {} + - name: tmp + emptyDir: + sizeLimit: 128Mi diff --git a/deploy/k8s/observability/loki/loki.yaml b/deploy/k8s/observability/loki/loki.yaml index 5ca66a3..30f5922 100644 --- a/deploy/k8s/observability/loki/loki.yaml +++ b/deploy/k8s/observability/loki/loki.yaml @@ -151,7 +151,7 @@ spec: # (hub.docker.com/r/grafana/loki/tags) and check the changelog for # schema or config breaking changes before bumping (schema migrations # are one-way — see schema_config above). - image: grafana/loki:3.4.2 + image: grafana/loki:3.4.2@sha256:58a6c186ce78ba04d58bfe2a927eff296ba733a430df09645d56cdc158f3ba08 args: ["-config.file=/etc/loki/loki.yaml"] ports: - name: http diff --git a/deploy/k8s/observability/otel-collector/otel-collector.yaml b/deploy/k8s/observability/otel-collector/otel-collector.yaml index 3a22f29..e085629 100644 --- a/deploy/k8s/observability/otel-collector/otel-collector.yaml +++ b/deploy/k8s/observability/otel-collector/otel-collector.yaml @@ -237,7 +237,7 @@ spec: # VERIFY before first deploy: confirm this tag exists on Docker Hub # (hub.docker.com/r/otel/opentelemetry-collector-contrib/tags) and # check the release notes for breaking pipeline config changes. - image: otel/opentelemetry-collector-contrib:0.119.0 + image: otel/opentelemetry-collector-contrib:0.119.0@sha256:36c35cc213c0f3b64d6e8a3e844dc90822f00725e0e518eaed5b08bcc2231e72 args: ["--config=/etc/otel/config.yaml"] ports: - name: otlp-grpc diff --git a/deploy/k8s/observability/prometheus/prometheus.yaml b/deploy/k8s/observability/prometheus/prometheus.yaml index 00f6c63..ce22451 100644 --- a/deploy/k8s/observability/prometheus/prometheus.yaml +++ b/deploy/k8s/observability/prometheus/prometheus.yaml @@ -225,7 +225,7 @@ spec: # VERIFY before first deploy: confirm this tag on Docker Hub # (hub.docker.com/r/prom/prometheus/tags) and review the CHANGELOG for # scrape config or TSDB breaking changes before bumping. - image: prom/prometheus:v3.1.0 + image: prom/prometheus:v3.1.0@sha256:6559acbd5d770b15bb3c954629ce190ac3cbbdb2b7f1c30f0385c4e05104e218 args: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" diff --git a/deploy/k8s/observability/tempo/tempo.yaml b/deploy/k8s/observability/tempo/tempo.yaml index 90d8e05..2936e3b 100644 --- a/deploy/k8s/observability/tempo/tempo.yaml +++ b/deploy/k8s/observability/tempo/tempo.yaml @@ -137,7 +137,7 @@ spec: # VERIFY before first deploy: confirm this tag exists on Docker Hub # (hub.docker.com/r/grafana/tempo/tags) and check the changelog for # storage-format or config breaking changes before bumping. - image: grafana/tempo:2.7.1 + image: grafana/tempo:2.7.1@sha256:4443be217c396b065ee34845534199c36fdba4dc619cb96550e228d73fba6e69 args: ["-config.file=/etc/tempo/tempo.yaml"] ports: - name: http-query diff --git a/deploy/llm/README.md b/deploy/llm/README.md index 66b2846..32113e2 100644 --- a/deploy/llm/README.md +++ b/deploy/llm/README.md @@ -56,3 +56,26 @@ kubectl --kubeconfig $KC -n fp-ml exec deploy/ollama -- \ TTS + cluster unharmed). - Scale-to-zero `ScaledObject`: **authored**, applied together with the L1 AI Gateway (which creates the `AI_REQUESTS` stream + publishes the warm signal). + +## Security context, and the move off root + +The pod runs with seccomp `RuntimeDefault`, every Linux capability dropped, no +privilege escalation and a read-only root filesystem (writes go to the model PVC +at `/root/.ollama` and an emptyDir at `/tmp`). It still runs as **uid 0**: the +`ollama-models` PVC was populated by a root process, including the `0600` +`id_ed25519` key Ollama reads at startup, and the k3s `local-path` provisioner +ignores `fsGroup`, so changing the uid in the manifest alone would crash the pod +on its own data. + +To finish the job (one-time, on the cluster): + +1. Scale to zero: `kubectl -n fp-ml scale deploy/ollama --replicas=0`. +2. Chown the volume to the target uid, e.g. with a throwaway pod that mounts + `ollama-models` and runs `chown -R 65532:65532 /data`. +3. In `ollama.yaml` set `runAsUser`/`runAsGroup: 65532`, `runAsNonRoot: true`, + `HOME=/home/ollama` and `OLLAMA_MODELS=/home/ollama/.ollama/models`, and move + the PVC mount to `/home/ollama/.ollama`. +4. Scale back up and confirm `ollama list` still shows the pulled model. + +`fp-ml` is also outside the namespaces the Kyverno policies match +(`deploy/policies`), so none of this is checked at admission yet. diff --git a/deploy/llm/ollama.yaml b/deploy/llm/ollama.yaml index dff2e3e..d6177a1 100644 --- a/deploy/llm/ollama.yaml +++ b/deploy/llm/ollama.yaml @@ -58,13 +58,39 @@ spec: spec: # Terminate fast on scale-down so the node reclaims RAM quickly. terminationGracePeriodSeconds: 20 + # Ollama never calls the Kubernetes API. + automountServiceAccountToken: false + # Pod-level hardening (Trivy KSV-0118: the pod previously ran with an + # entirely default security context). seccomp RuntimeDefault filters the + # syscall surface. The process still runs as uid 0 - see the container + # securityContext for why and for what it no longer has. + securityContext: + seccompProfile: + type: RuntimeDefault containers: - name: ollama - image: ollama/ollama:0.30.10 + # Tag for humans, digest for the kubelet: Docker Hub tags (even version tags) + # can be re-pushed, so the digest is what pins the bytes. Bump both together. + image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee imagePullPolicy: IfNotPresent ports: - name: http containerPort: 11434 + # STILL ROOT (uid 0), deliberately, for now: the existing ollama-models + # PVC was populated by a root process (including the 0600 id_ed25519 + # key Ollama reads at startup), and local-path volumes ignore fsGroup, + # so switching uid here would crash the pod on its own data. Moving to + # a non-root uid needs a one-time chown of the PVC; tracked in + # deploy/llm/README.md. Meanwhile root is defanged: ALL capabilities + # dropped (no CAP_DAC_OVERRIDE, CAP_CHOWN, CAP_NET_RAW, ...), no + # privilege escalation, and a read-only root FS (Trivy KSV-0014) so a + # compromised runner cannot modify the image's binaries. Writes go to + # the model PVC (/root/.ollama) and /tmp only. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] env: # Keep a model warm for 2m between requests so rapid embed/judge/chat # calls don't each cold-load from disk (which timed out the cache embed @@ -95,6 +121,8 @@ spec: volumeMounts: - name: models mountPath: /root/.ollama + - name: tmp + mountPath: /tmp # The model loads lazily on the first request (keep_alive=0), so readiness # is just the HTTP server being up, not a model being resident. readinessProbe: @@ -113,6 +141,9 @@ spec: - name: models persistentVolumeClaim: claimName: ollama-models + - name: tmp + emptyDir: + sizeLimit: 512Mi --- apiVersion: v1 kind: Service diff --git a/deploy/policies/README.md b/deploy/policies/README.md index 151a75c..46230a5 100644 --- a/deploy/policies/README.md +++ b/deploy/policies/README.md @@ -21,11 +21,12 @@ This is part of milestone **M6 — Platform security, GitOps & autoscaling** | `require-run-as-nonroot` | `runAsNonRoot=true` (pod or every container) | fp-auth podSecurityContext; infra `runAsUser` 999/1000 | | `disallow-privilege-escalation` | `allowPrivilegeEscalation=false` on all containers | every container securityContext | | `drop-all-capabilities` | `capabilities.drop` contains `ALL` | every container securityContext | -| `require-readonly-rootfs` | `readOnlyRootFilesystem=true` (**exception:** `app.kubernetes.io/name=postgres`) | fp-auth, redis, nats, minio | +| `require-readonly-rootfs` | `readOnlyRootFilesystem=true` (no exceptions) | fp-auth, redis, nats, minio, postgres | | `require-seccomp-runtimedefault` | `seccompProfile.type` is `RuntimeDefault`/`Localhost` | all pods' podSecurityContext | | `require-resource-requests-and-limits` | cpu+memory **requests AND limits** on every container | fp-auth `resources`; all infra pods | | `disallow-latest-tag` | image is tagged and not `:latest` | pinned infra tags; appVersion tag | -| `restrict-image-registries` | image from the **allowlist** (parameterized) | ghcr today, ECR in prod, distroless, pinned infra | +| `restrict-image-registries` | image from the **allowlist** (parameterized) | ghcr, distroless, migrate, pinned infra and observability images; ECR only per-account in the EKS overlay | +| `verify-image-signatures` | `ghcr.io/abd-ulbasit/forgepoint/*` carries a cosign **keyless** signature from this repo's `security.yml` on `main` or a `v*` tag | the Security workflow's `publish` job | | `require-part-of-label` | `app.kubernetes.io/part-of=forgepoint` on pod templates | recommended labels everywhere | | `disallow-host-namespaces` | no `hostPID`/`hostIPC`/`hostNetwork` | (none of ours use them) | | `disallow-host-path` | no `hostPath` volumes | we use PVCs/emptyDir only | @@ -51,16 +52,14 @@ audit** trail): it's disallowed in non-dev. `fp-dev` is excluded so the permissive dev default stays frictionless locally. -### The documented `readOnlyRootFilesystem` exception +### No `readOnlyRootFilesystem` exception -`require-readonly-rootfs` **excludes** pods labeled -`app.kubernetes.io/name=postgres`. The official `postgres` image's entrypoint -writes to transient paths and does not cleanly support a fully read-only root FS -(see the comments in `deploy/k8s/infra/postgres/postgres.yaml`), so postgres -confines writes to its PVC + an emptyDir socket dir instead. The carve-out is a -**narrow, named, label-selected** exclusion — not a blanket hole. Adding another -genuinely-incompatible image means extending that exclusion explicitly and -documenting why. +`require-readonly-rootfs` used to exclude pods labeled +`app.kubernetes.io/name=postgres`, on the belief that the official image could not +run with a read-only root FS. It can: PGDATA lives on the PVC and the socket dir +and `/tmp` are emptyDirs (see `deploy/k8s/infra/postgres/postgres.yaml`), so the +exception was removed. If an image genuinely cannot run read-only, add a +**narrow, named, label-selected** exclusion and document why next to it. ### The parameterized registry allowlist @@ -69,11 +68,36 @@ documenting why. `base/20-image-policies.yaml`. Add or remove a trusted registry by editing that single list. It currently allows: -- `*.dkr.ecr.*.amazonaws.com/*` — prod ECR (any account/region; M5 AWS plan) -- `ghcr.io/abd-ulbasit/forgepoint/*` — where the chart points **today** +- `ghcr.io/abd-ulbasit/forgepoint/*` — where every chart and Rollout points and + where CI publishes - `gcr.io/distroless/*` — the distroless runtime base (hermetic builds) +- `migrate/migrate` — the golang-migrate initContainer in the Postgres-backed + charts (previously missing: Enforce would have blocked every migration) - the **specific pinned infra images**: `postgres`, `redis`, `nats`, `minio/minio`, `minio/mc` (both short and `docker.io/...` spellings) +- the **specific pinned observability images** in `fp-infra`: + `grafana/grafana`, `grafana/loki`, `grafana/tempo`, `prom/prometheus`, + `otel/opentelemetry-collector-contrib` (both spellings; previously missing, so + Enforce would have blocked the whole observability plane) + +`ollama/ollama` (`deploy/llm`) runs in `fp-ml`, which these policies do not +match, so it needs no entry unless the scope grows. + +There is deliberately **no ECR entry** in the base. The old +`*.dkr.ecr.*.amazonaws.com/*` glob matched any AWS account's registry, an +attacker's included. The EKS overlay adds exactly one, e.g. +`123456789012.dkr.ecr.us-east-1.amazonaws.com/forgepoint-dev/*`. + +### Signature verification + +The allowlist says *where* an image may come from; `verify-image-signatures` +(`base/50-verify-images.yaml`) checks *who built it*. Every image the Security +workflow publishes is signed with cosign keyless. The policy requires, for +`ghcr.io/abd-ulbasit/forgepoint/*`, a Fulcio certificate with issuer +`https://token.actions.githubusercontent.com` and a subject matching +`https://github.com/abd-ulbasit/forgepoint/.github/workflows/security.yml@refs/(heads/main|tags/v.*)`. +It ships in Audit with `mutateDigest`/`verifyDigest` off. When it moves to +Enforce, turn both on so the pod runs the exact digest that was verified. --- @@ -162,7 +186,7 @@ These Kyverno policies **complement** — they don't replace — PodSecurity: | | Pod Security admission | Kyverno (these policies) | |---|---|---| | Granularity | 3 fixed profiles, all-or-nothing per namespace | per-rule, fine-grained, individually toggled | -| Exceptions | none built in (exempt = whole namespace) | **narrow, labeled** (e.g. postgres readonly-rootfs) | +| Exceptions | none built in (exempt = whole namespace) | **narrow, labeled** (selector-based `exclude`) | | Coverage | pod securityContext only | + **registries, image tags, resource limits, labels, hostPort** | | Reporting | deny only (no "audit-but-allow" inventory of *all* violations) | rich PolicyReports, background scan of existing objects | | Custom rules | not possible | yes (any field, JMESPath conditions) | @@ -189,7 +213,7 @@ deploy/policies/ ├── base/ ← all policies, validationFailureAction: Audit │ ├── kustomization.yaml │ ├── 00-pod-security.yaml ← nonroot, no-priv-esc, drop-all-caps, -│ │ readonly-rootfs (+postgres exception), +│ │ readonly-rootfs, │ │ seccomp RuntimeDefault │ ├── 10-require-resources.yaml ← requests + limits (cpu & memory) │ ├── 20-image-policies.yaml ← disallow-latest-tag, restrict-registries diff --git a/deploy/policies/base/00-pod-security.yaml b/deploy/policies/base/00-pod-security.yaml index c948161..7d2d9a1 100644 --- a/deploy/policies/base/00-pod-security.yaml +++ b/deploy/policies/base/00-pod-security.yaml @@ -371,21 +371,18 @@ spec: --- # ----------------------------------------------------------------------------- -# require-readonly-rootfs (WITH a documented exception for stateful infra) +# require-readonly-rootfs (no exceptions) # ----------------------------------------------------------------------------- # readOnlyRootFilesystem=true means the container can't write to its own image # layers — it must mount an emptyDir/PVC for any writable path. This neuters a # whole class of attacks (dropping a binary, tampering with config) and is set -# on fp-auth, redis, nats, and minio. +# on fp-auth, redis, nats, minio and postgres. # -# THE EXCEPTION (read the postgres manifest): the official postgres image's -# entrypoint writes to a few transient paths and does not cleanly support a -# fully read-only root FS, so postgres deliberately omits readOnlyRootFilesystem -# and instead confines writes to a PVC + an emptyDir socket dir. We encode that -# as a precondition-based exclusion keyed on the well-known label -# app.kubernetes.io/name=postgres, so the exception is NARROW, named, and -# auditable — not a blanket hole. If we add another image that genuinely can't -# run read-only, we extend this allowlist explicitly (and document why). +# There used to be a label-selected exception for postgres, on the belief that +# the official image could not run read-only. It can (PGDATA on the PVC, the +# socket dir and /tmp on emptyDirs; see deploy/k8s/infra/postgres/postgres.yaml), +# so the exception is gone. If an image genuinely cannot run read-only, add a +# NARROW, label-selected exclusion here and document why next to it. apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: @@ -396,10 +393,7 @@ metadata: policies.kyverno.io/severity: medium policies.kyverno.io/subject: Pod policies.kyverno.io/description: >- - Containers must set securityContext.readOnlyRootFilesystem=true. A narrow, - documented exception applies to stateful infra whose image cannot run with - a read-only root FS (currently: postgres), selected by the - app.kubernetes.io/name label. + Containers must set securityContext.readOnlyRootFilesystem=true. spec: validationFailureAction: Audit background: true @@ -422,18 +416,10 @@ spec: - kube-system - kube-node-lease - kube-public - # DOCUMENTED EXCEPTION: postgres cannot run read-only (see - # deploy/k8s/infra/postgres/postgres.yaml comments). Exclude by its - # platform label so the carve-out is explicit and greppable. - - resources: - selector: - matchLabels: - app.kubernetes.io/name: postgres validate: message: >- readOnlyRootFilesystem must be true on every container. Mount an - emptyDir/PVC for any writable path (e.g. /tmp). Documented exception: - stateful infra labeled app.kubernetes.io/name=postgres. + emptyDir/PVC for any writable path (e.g. /tmp). pattern: spec: =(ephemeralContainers): diff --git a/deploy/policies/base/20-image-policies.yaml b/deploy/policies/base/20-image-policies.yaml index 51b7548..70ac46c 100644 --- a/deploy/policies/base/20-image-policies.yaml +++ b/deploy/policies/base/20-image-policies.yaml @@ -17,8 +17,11 @@ # impossible, and a poisoned upstream tag silently lands in prod. Pinned tags # (and ideally digests) make deployments reproducible and auditable — this is a # 12-Factor "build, release, run" separation and a "hermetic builds" -# requirement. Our infra images are already pinned (postgres:17, redis:7, -# nats:2.11, minio/...:RELEASE.); fp-auth uses appVersion as the tag. +# requirement. The infra images are pinned by tag AND digest (e.g. +# postgres:17.11@sha256:...), because a Docker Hub tag, even a version tag, can be +# re-pushed. (An earlier revision of this comment called postgres:17, redis:7 and +# nats:2.11 "pinned"; those are floating tags.) The fp-* charts use +# Chart.appVersion as the tag. # # We assert TWO things: (1) a tag is present at all, and (2) it isn't `latest`. apiVersion: kyverno.io/v1 @@ -106,17 +109,28 @@ spec: # and the per-collection rules below reference it — no duplication, no drift. # # The allowlist below covers, intentionally: -# - .dkr.ecr..amazonaws.com/* → our prod ECR (M5 AWS plan). -# Wildcarded on account+region so we don't hard-code one account here. -# - ghcr.io/abd-ulbasit/forgepoint/* → where fp-auth's chart points -# TODAY (values.yaml). Keeping it prevents the current platform from -# regressing the moment we flip to Enforce. +# - ghcr.io/abd-ulbasit/forgepoint/* → where every chart, Rollout +# and (since 2026-10) the Security workflow's publish job point. Signatures +# on these are checked separately by 50-verify-images.yaml. +# - NO ECR entry in the base. It used to allow "*.dkr.ecr.*.amazonaws.com/*", +# and Kyverno's `*` also matches the account id, so that admitted images +# from ANY AWS account's registry, an attacker's included. Nothing deploys +# from ECR yet. The EKS overlay must add the ONE account and repo prefix it +# uses, e.g. "123456789012.dkr.ecr.us-east-1.amazonaws.com/forgepoint-dev/*". +# - migrate/migrate → the golang-migrate +# initContainer every Postgres-backed chart runs (pinned by digest). It was +# missing, so flipping to Enforce would have blocked every schema migration. # - gcr.io/distroless/* → distroless runtime base # (the hermetic-builds requirement). # - the PINNED infra images: docker.io/library/{postgres,redis,nats} and # docker.io/minio/{minio,mc}. We pin the FULL repo path (not a bare # "docker.io/*") so only these specific, reviewed infra images are allowed — # not all of Docker Hub. +# - the PINNED observability images in fp-infra (deploy/k8s/observability): +# grafana/{grafana,loki,tempo}, prom/prometheus and +# otel/opentelemetry-collector-contrib. They were missing, so flipping to +# Enforce would have blocked the whole observability plane. Same rule: one +# full repo path each, never "grafana/*" or "docker.io/*". # # HOW THE CHECK WORKS: for each container we test whether its image matches ANY # entry in `allowedRegistries`. JMESPath has no "matches any glob" builtin, so we @@ -140,9 +154,10 @@ metadata: policies.kyverno.io/severity: high policies.kyverno.io/subject: Pod policies.kyverno.io/description: >- - Container images may only come from an allowlisted set of registries: the - platform ECR (prod), ghcr.io/abd-ulbasit/forgepoint (current), the - distroless base, and the specific pinned infra images. + Container images may only come from an allowlisted set of registries: + ghcr.io/abd-ulbasit/forgepoint, the distroless base, the golang-migrate + initContainer and the specific pinned infra images. ECR is added per + environment for one named account, never as a wildcard. spec: validationFailureAction: Audit background: true @@ -174,8 +189,9 @@ spec: # `allowedRegistries`. Edit this list to add/remove a trusted registry. variable: value: - - "*.dkr.ecr.*.amazonaws.com/*" # prod ECR (any account/region) - - "ghcr.io/abd-ulbasit/forgepoint/*" # current GHCR images + # ECR: add ".dkr.ecr..amazonaws.com/forgepoint-/*" + # in the EKS overlay. Never wildcard the account (see above). + - "ghcr.io/abd-ulbasit/forgepoint/*" # platform images (CI-published) - "gcr.io/distroless/*" # distroless runtime base # Pinned infra images (full repo path, both spellings). We include # BOTH the docker.io-qualified form (what Kyverno normalizes to) AND @@ -191,12 +207,30 @@ spec: - "docker.io/minio/minio:*" - "minio/mc:*" - "docker.io/minio/mc:*" + # golang-migrate initContainer in the fp-* charts. + - "migrate/migrate:*" + - "docker.io/migrate/migrate:*" + # Pinned observability images (deploy/k8s/observability, fp-infra). + - "grafana/grafana:*" + - "docker.io/grafana/grafana:*" + - "grafana/loki:*" + - "docker.io/grafana/loki:*" + - "grafana/tempo:*" + - "docker.io/grafana/tempo:*" + - "prom/prometheus:*" + - "docker.io/prom/prometheus:*" + - "otel/opentelemetry-collector-contrib:*" + - "docker.io/otel/opentelemetry-collector-contrib:*" validate: message: >- - Image registry is not allowlisted. Allowed: ECR - (*.dkr.ecr.*.amazonaws.com), ghcr.io/abd-ulbasit/forgepoint, - gcr.io/distroless, and the pinned infra images (postgres, redis, nats, - minio/minio, minio/mc). Edit restrict-image-registries to add one. + Image registry is not allowlisted. Allowed: + ghcr.io/abd-ulbasit/forgepoint, gcr.io/distroless, migrate/migrate, + the pinned infra images (postgres, redis, nats, minio/minio, + minio/mc) and the pinned observability images (grafana/grafana, + grafana/loki, grafana/tempo, prom/prometheus, + otel/opentelemetry-collector-contrib). ECR is allowed only for the + single account added by the EKS overlay. Edit + restrict-image-registries to add one. # foreach + deny: for each container, count how many allowlist globs the # image matches; deny when ZERO match. # diff --git a/deploy/policies/base/50-verify-images.yaml b/deploy/policies/base/50-verify-images.yaml new file mode 100644 index 0000000..9b960bb --- /dev/null +++ b/deploy/policies/base/50-verify-images.yaml @@ -0,0 +1,76 @@ +# ============================================================================= +# verify-image-signatures — admission-time check of the cosign signatures CI makes +# ============================================================================= +# +# The Security workflow's `publish` job signs every image it pushes (main and +# v* tags) with cosign KEYLESS: the signature is bound to the workflow's GitHub +# OIDC identity through a short-lived Fulcio certificate and logged in Rekor. +# Until this policy existed nothing in the cluster checked that signature, so +# signing stopped no attack at all: anyone able to push to the GHCR path, or to +# point a manifest at a look-alike image, could run unsigned code. +# +# WHAT IS PINNED (both must match, or the image is rejected): +# issuer : https://token.actions.githubusercontent.com (GitHub Actions OIDC) +# subject : https://github.com/abd-ulbasit/forgepoint/.github/workflows/security.yml@refs/heads/main +# or ...@refs/tags/v +# i.e. "signed by THIS repo's Security workflow, running on main or a release +# tag". A fork, another repo, another workflow file, or a PR run cannot produce +# a certificate with that subject. +# +# AUDIT FIRST, like every policy in base/ (the enforce overlay flips it). +# - mutateDigest: false. Kyverno would otherwise rewrite tags to digests, a +# mutation that is wrong to make while only auditing. Turn it on with +# Enforce, so the pod runs exactly the digest that was verified. +# - verifyDigest: false for the same reason: the charts still deploy by tag +# (Chart.appVersion). Flip both when CI pins digests in Git. +# - No forgepoint image had ever been published when this policy was added +# (the image job never ran on main), so the first signed images arrive +# with the next push to main or release tag. Until then every fp-* pod +# shows up as an audit failure, which is accurate. +# +# Kyverno needs network access to ghcr.io (signature) and rekor.sigstore.dev +# (transparency log) from its admission controller. +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: verify-image-signatures + annotations: + policies.kyverno.io/title: Verify Image Signatures + policies.kyverno.io/category: Supply Chain Security + policies.kyverno.io/severity: high + policies.kyverno.io/subject: Pod + policies.kyverno.io/description: >- + Images from ghcr.io/abd-ulbasit/forgepoint must carry a cosign keyless + signature made by this repository's Security workflow on main or a v* tag. +spec: + validationFailureAction: Audit + # Image verification runs at admission; background re-scans would re-fetch + # signatures for every existing pod on every cycle. + background: false + webhookTimeoutSeconds: 30 + rules: + - name: verify-forgepoint-cosign-keyless + match: + any: + - resources: + kinds: + - Pod + namespaces: + - fp-system + - fp-models + - fp-jobs + - fp-infra + verifyImages: + - imageReferences: + - "ghcr.io/abd-ulbasit/forgepoint/*" + mutateDigest: false + verifyDigest: false + required: true + attestors: + - count: 1 + entries: + - keyless: + issuer: "https://token.actions.githubusercontent.com" + subjectRegExp: '^https://github\.com/abd-ulbasit/forgepoint/\.github/workflows/security\.yml@refs/(heads/main|tags/v.*)$' + rekor: + url: https://rekor.sigstore.dev diff --git a/deploy/policies/base/kustomization.yaml b/deploy/policies/base/kustomization.yaml index 1d1bcf6..ffa252b 100644 --- a/deploy/policies/base/kustomization.yaml +++ b/deploy/policies/base/kustomization.yaml @@ -18,3 +18,4 @@ resources: - 20-image-policies.yaml - 30-workload-policies.yaml - 40-ai-gateway-policies.yaml + - 50-verify-images.yaml diff --git a/deploy/rollouts/README.md b/deploy/rollouts/README.md index 4a649f8..b32a89a 100644 --- a/deploy/rollouts/README.md +++ b/deploy/rollouts/README.md @@ -96,11 +96,16 @@ PDB, ServiceMonitor) remain managed by Helm. The Rollout owns only the pod templ The AnalysisTemplates query: ``` -http://prometheus-operated.fp-infra.svc.cluster.local:9090 +http://prometheus.fp-infra.svc.cluster.local:9090 ``` -This is the kube-prometheus-stack (or Prometheus Operator) address. Adjust if -your Prometheus service has a different name or namespace. +That is the `prometheus` Service in `deploy/k8s/observability/prometheus/`. (Earlier +revisions pointed at `prometheus-operated`, the Service name the Prometheus +*Operator* creates; this repo runs plain Prometheus, so that name never resolved +and every analysis run would have failed.) + +Adjust the address in both AnalysisTemplates if your Prometheus Service has a +different name or namespace. --- @@ -164,7 +169,7 @@ instruments OpenTelemetry metrics: 1. Find the actual metric names: ```bash - kubectl port-forward svc/prometheus-operated 9090:9090 -n fp-infra + kubectl port-forward svc/prometheus 9090:9090 -n fp-infra # Open http://localhost:9090 and search for "forgepoint_" or "grpc_server_" ``` diff --git a/deploy/rollouts/analysis-template-inference-gateway.yaml b/deploy/rollouts/analysis-template-inference-gateway.yaml index b0b54d6..8cda2c4 100644 --- a/deploy/rollouts/analysis-template-inference-gateway.yaml +++ b/deploy/rollouts/analysis-template-inference-gateway.yaml @@ -92,7 +92,7 @@ spec: # Prometheus endpoint — the in-cluster address of the Prometheus # server deployed in Phase 10 (or the OTel Collector's Prometheus # remote-write receiver). - address: http://prometheus-operated.fp-infra.svc.cluster.local:9090 + address: http://prometheus.fp-infra.svc.cluster.local:9090 # Query: 1 - (error_rate). Uses the `service` arg from the Rollout. # PLACEHOLDER: replace metric names with actual OTel-emitted names. @@ -120,7 +120,7 @@ spec: provider: prometheus: - address: http://prometheus-operated.fp-infra.svc.cluster.local:9090 + address: http://prometheus.fp-infra.svc.cluster.local:9090 # histogram_quantile over the grpc/http duration histogram. # PLACEHOLDER: replace with the actual OTel duration histogram metric name. diff --git a/deploy/rollouts/analysis-template-model-serving.yaml b/deploy/rollouts/analysis-template-model-serving.yaml index f145830..6e665cb 100644 --- a/deploy/rollouts/analysis-template-model-serving.yaml +++ b/deploy/rollouts/analysis-template-model-serving.yaml @@ -58,7 +58,7 @@ spec: provider: prometheus: - address: http://prometheus-operated.fp-infra.svc.cluster.local:9090 + address: http://prometheus.fp-infra.svc.cluster.local:9090 # PLACEHOLDER: replace with actual OTel-emitted metric name for # model-serving gRPC errors (e.g. grpc_server_handled_total with @@ -98,7 +98,7 @@ spec: provider: prometheus: - address: http://prometheus-operated.fp-infra.svc.cluster.local:9090 + address: http://prometheus.fp-infra.svc.cluster.local:9090 # PLACEHOLDER: replace with the actual gRPC server duration histogram. # grpc_server_handling_seconds_bucket is the typical name from the @@ -155,7 +155,7 @@ spec: provider: prometheus: - address: http://prometheus-operated.fp-infra.svc.cluster.local:9090 + address: http://prometheus.fp-infra.svc.cluster.local:9090 # PLACEHOLDER: replace with the actual Prometheus metric name that # model-monitor exposes for drift score. This is a gauge that diff --git a/deploy/rollouts/kustomization.yaml b/deploy/rollouts/kustomization.yaml index fe3f466..1a0b5ab 100644 --- a/deploy/rollouts/kustomization.yaml +++ b/deploy/rollouts/kustomization.yaml @@ -75,6 +75,20 @@ resources: # model-serving: success-rate + latency + drift-score (closed-loop gate). - analysis-template-model-serving.yaml +# THE deployed image tags, in one place. Bump with e.g. +# kustomize edit set image ghcr.io/abd-ulbasit/forgepoint/model-serving:0.2.0 +# to a tag the Security workflow published (semver or sha-<40>). +# PLACEHOLDER: 0.1.0 matches Chart.appVersion, the tag the Helm charts fall back +# to, but NO image has been published yet (GHCR has none for these repos, and +# the existing v0.1.0 git tag predates the publish job). These Rollouts will sit +# in ImagePullBackOff until a release goes through a green Security pipeline: +# push a new v* tag once the gates pass, then set that version here. +images: + - name: ghcr.io/abd-ulbasit/forgepoint/inference-gateway + newTag: "0.1.0" + - name: ghcr.io/abd-ulbasit/forgepoint/model-serving + newTag: "0.1.0" + labels: - pairs: forgepoint.io/managed-by-phase: "21" diff --git a/deploy/rollouts/rollout-inference-gateway.yaml b/deploy/rollouts/rollout-inference-gateway.yaml index 2d48ebd..586910c 100644 --- a/deploy/rollouts/rollout-inference-gateway.yaml +++ b/deploy/rollouts/rollout-inference-gateway.yaml @@ -123,11 +123,12 @@ spec: containers: - name: inference-gateway - # Image tag is overridden per deploy via: + # Pinned tag (never :latest, which Kyverno disallow-latest-tag rejects and + # which makes rollback meaningless). Set the deployed tag in ONE place, + # the images: block of kustomization.yaml (or for a one-off canary: # kubectl argo rollouts set image fp-inference-gateway \ - # inference-gateway=ghcr.io/abd-ulbasit/forgepoint/inference-gateway: - # or via ArgoCD image updater. - image: ghcr.io/abd-ulbasit/forgepoint/inference-gateway:latest + # inference-gateway=ghcr.io/abd-ulbasit/forgepoint/inference-gateway:). + image: ghcr.io/abd-ulbasit/forgepoint/inference-gateway:0.1.0 imagePullPolicy: IfNotPresent ports: diff --git a/deploy/rollouts/rollout-model-serving.yaml b/deploy/rollouts/rollout-model-serving.yaml index 59fc14c..81c6ed6 100644 --- a/deploy/rollouts/rollout-model-serving.yaml +++ b/deploy/rollouts/rollout-model-serving.yaml @@ -6,8 +6,8 @@ # MODEL SERVING CANARY — ADDITIONAL CONSIDERATIONS # -------------------------------------------------- # model-serving is DIFFERENT from inference-gateway in one critical way: -# it runs a sidecar-based ONNX serving pattern where the sidecar (the model -# file downloader + ONNX runtime) and the main container are tightly coupled. +# the binary fetches model artifacts itself (in-process ModelFetcher) and +# serves them, so the code and the model it loads are tightly coupled. # A "new version" of model-serving may mean: # (a) a new service binary (Go code change), OR # (b) a new model version artifact (model weights change), OR @@ -92,48 +92,21 @@ spec: type: RuntimeDefault # ----------------------------------------------------------------------- - # Sidecar pattern: model-artifact-fetcher runs first (initContainer) to - # download model weights into a shared emptyDir; the ONNX runtime in the - # main container reads from that volume. This matches the Phase 3 design - # (Model Serving — Sidecar, HPA on custom metrics). - # The sidecar image tag is separately versioned from the main container; - # update them independently via `kubectl argo rollouts set image`. + # No init/sidecar fetcher. An earlier revision ran a + # "model-artifact-fetcher:latest" initContainer here; that image was never + # built (there is no such directory under services/), so every canary pod + # would have sat in ImagePullBackOff. model-serving fetches artifacts + # IN-PROCESS (ModelFetcher: file:// and http(s)://, see + # services/model-serving/cmd/server/main.go) into its scratch dir, which + # defaults to the OS temp dir: /tmp below, the same emptyDir the Helm + # chart mounts. # ----------------------------------------------------------------------- - initContainers: - - name: model-artifact-fetcher - image: ghcr.io/abd-ulbasit/forgepoint/model-artifact-fetcher:latest - imagePullPolicy: IfNotPresent - command: - - /fetcher - - --artifact-path=$(MODEL_ARTIFACT_PATH) - - --output-dir=/models - envFrom: - - configMapRef: - name: fp-model-serving - - secretRef: - name: fp-model-serving - volumeMounts: - - name: model-weights - mountPath: /models - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 65532 - capabilities: - drop: - - ALL - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 256Mi - containers: - name: model-serving - image: ghcr.io/abd-ulbasit/forgepoint/model-serving:latest + # Pinned tag (never :latest, which Kyverno disallow-latest-tag rejects and + # which makes rollback meaningless). The deployed tag is set in ONE + # place, the images: block of kustomization.yaml; 0.1.0 = Chart.appVersion. + image: ghcr.io/abd-ulbasit/forgepoint/model-serving:0.1.0 imagePullPolicy: IfNotPresent ports: @@ -151,9 +124,9 @@ spec: name: fp-model-serving volumeMounts: - - name: model-weights - mountPath: /models - readOnly: true + # Writable scratch for the fetched artifact (read-only root FS). + - name: tmp + mountPath: /tmp securityContext: allowPrivilegeEscalation: false @@ -201,8 +174,11 @@ spec: timeoutSeconds: 3 volumes: - - name: model-weights - emptyDir: {} + - name: tmp + # Same as the chart: node-disk-backed, capped so an oversized artifact + # cannot fill the node. + emptyDir: + sizeLimit: 2Gi strategy: canary: diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index fff8569..4874417 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -69,18 +69,19 @@ Files: `arn:aws:iam:::role/forgepoint-dev-irsa-external_secrets`. - [`externalsecret-fp-auth.yaml`](./external-secrets/externalsecret-fp-auth.yaml) — materializes Secret `fp-auth` with `FP_JWT_SECRET` (from `/auth/jwt`) and - `FP_DATABASE_URL` (the `dsn` field of the RDS module's `/rds/postgres`). + `FP_DATABASE_URL` (templated from the RDS-managed password secret plus the + RDS module's `/rds/postgres` connection info). - [`externalsecret-fp-infra-creds.yaml`](./external-secrets/externalsecret-fp-infra-creds.yaml) — materializes Secret `fp-infra-creds` with the shared Postgres + Redis connection - info (RDS `dsn`/host/port + ElastiCache `url`/host/port/`auth_token`). + info (templated Postgres DSN + host/port, ElastiCache `url`/host/port/`auth_token`). ### Secret name ↔ Terraform source (no values, only names) | K8s Secret key | AWS SM secret (name) | JSON property | Terraform source | |---|---|---|---| -| `FP_DATABASE_URL` | `/rds/postgres` | `dsn` | `modules/rds` | +| `FP_DATABASE_URL` (templated) | RDS-managed `rds!db-...` (by ARN) + `/rds/postgres` | `username`/`password` + `host`/`port`/`dbname`/`sslmode` | `modules/rds` (`manage_master_user_password`) | | `POSTGRES_HOST`/`PORT` | `/rds/postgres` | `host`/`port` | `modules/rds` | -| `FP_REDIS_URL` | `/elasticache/redis` | `url` | `modules/elasticache` | +| `FP_REDIS_URL` | `/elasticache/redis` | `url` (`rediss://`, token percent-encoded) | `modules/elasticache` | | `REDIS_*` | `/elasticache/redis` | `host`/`port`/`auth_token` | `modules/elasticache` | | `FP_JWT_SECRET` | `/auth/jwt` | `jwt_secret` | operator-created (see note) | @@ -91,6 +92,13 @@ Files: > and adds its ARN to the `external_secrets` IRSA role's `secret_arns` in > `environments/dev/main.tf`. The generated key never touches Git. +> **Note on the Postgres password:** RDS generates, stores and rotates it +> (`manage_master_user_password`), so it never appears in Terraform state. The +> secret's name is generated, so the ExternalSecrets reference it by ARN: replace +> `` with the `rds_master_user_secret_arn` output. RDS +> rotates it every 7 days by default; env vars only change on pod restart, so run a +> reloader (or a scheduled rollout) or new connections will fail after a rotation. + ## Local path: Sealed Secrets (`sealed-secrets/`) For the k3s homelab with no AWS. Encrypt a Secret locally with `kubeseal`, commit diff --git a/deploy/secrets/external-secrets/externalsecret-fp-auth.yaml b/deploy/secrets/external-secrets/externalsecret-fp-auth.yaml index 5358afe..f45b019 100644 --- a/deploy/secrets/external-secrets/externalsecret-fp-auth.yaml +++ b/deploy/secrets/external-secrets/externalsecret-fp-auth.yaml @@ -12,9 +12,16 @@ # # WHY TWO DIFFERENT SOURCES FOR ONE TARGET SECRET # - FP_DATABASE_URL: the Postgres DSN is INFRASTRUCTURE the RDS Terraform module -# owns. It is stored as a JSON blob at "/rds/postgres" with a ready -# "dsn" field (postgres://user:pass@host:5432/db?sslmode=require). We pull the -# `dsn` property straight out — no string assembly, no password in Git. +# owns, and it is ASSEMBLED HERE from two sources so the password never passes +# through Terraform state: +# * username + password from the secret RDS itself creates and rotates +# (manage_master_user_password). Its name is generated (rds!db-...), so it +# is referenced by ARN: replace with the +# `rds_master_user_secret_arn` Terraform output. +# * host, port, dbname, sslmode from "/rds/postgres", a +# connection-info secret with no password in it. +# The template below URL-escapes the credentials into +# postgres://user:pass@host:port/db?sslmode=require. No password in Git. # - FP_JWT_SECRET: the HMAC-SHA256 signing key is an APPLICATION secret, not a # cloud resource, so Terraform does NOT create it (it has no business minting # app keys). Instead it lives in its own Secrets Manager secret @@ -31,10 +38,12 @@ # vars the service expects — no stray `host`/`port`/`username` keys leaking in as # env. Explicit is safer and self-documenting. # -# refreshInterval: ESO re-reads the backend on this cadence. If the RDS password is -# rotated in Secrets Manager (e.g. by AWS managed rotation), ESO repulls and -# updates the K8s Secret within ~1h; pair with a reloader to restart pods, or rely -# on the service reconnecting. Shorter = fresher but more AWS API calls. +# refreshInterval: ESO re-reads the backend on this cadence. RDS rotates the +# managed master password (every 7 days by default); ESO repulls and updates the +# K8s Secret within ~1h. Env vars from envFrom only change when a pod restarts, +# so pair this with a reloader (or a scheduled rollout): open connections survive +# a rotation, NEW connections need the new password. Shorter = fresher but more +# AWS API calls. # ============================================================================ apiVersion: external-secrets.io/v1 kind: ExternalSecret @@ -62,18 +71,46 @@ spec: template: # Opaque + these become env vars via the Deployment's envFrom. Key names # match what services/auth/cmd/server reads (FP_JWT_SECRET, FP_DATABASE_URL). + # With template.data set, ONLY these keys land in the Secret; the fetched + # pg_* values below are template inputs, not env vars. type: Opaque + engineVersion: v2 + data: + FP_JWT_SECRET: "{{ .jwt_secret }}" + FP_DATABASE_URL: "postgres://{{ .pg_username | urlquery }}:{{ .pg_password | urlquery }}@{{ .pg_host }}:{{ .pg_port }}/{{ .pg_dbname }}?sslmode={{ .pg_sslmode }}" data: # ---- JWT signing key (app secret, "/auth/jwt" JSON -> jwt_secret) ---- - - secretKey: FP_JWT_SECRET + - secretKey: jwt_secret remoteRef: # PLACEHOLDER name — matches the operator-created SM secret described above. key: "forgepoint-dev/auth/jwt" property: jwt_secret - # ---- Postgres DSN (infra secret, RDS module "/rds/postgres" -> dsn) -- - - secretKey: FP_DATABASE_URL + # ---- Postgres credentials (RDS-managed secret, rotated by RDS) ------------- + - secretKey: pg_username + remoteRef: + # PLACEHOLDER: the `rds_master_user_secret_arn` Terraform output. + key: "" + property: username + - secretKey: pg_password + remoteRef: + key: "" + property: password + # ---- Postgres connection info (RDS module "/rds/postgres") -------- + # Matches aws_secretsmanager_secret.db.name = "${name_prefix}/rds/postgres" + # (deploy/terraform/modules/rds/main.tf). PLACEHOLDER prefix shown. + - secretKey: pg_host + remoteRef: + key: "forgepoint-dev/rds/postgres" + property: host + - secretKey: pg_port + remoteRef: + key: "forgepoint-dev/rds/postgres" + property: port + - secretKey: pg_dbname + remoteRef: + key: "forgepoint-dev/rds/postgres" + property: dbname + - secretKey: pg_sslmode remoteRef: - # Matches aws_secretsmanager_secret.db.name = "${name_prefix}/rds/postgres" - # (deploy/terraform/modules/rds/main.tf). PLACEHOLDER prefix shown. key: "forgepoint-dev/rds/postgres" - property: dsn + property: sslmode diff --git a/deploy/secrets/external-secrets/externalsecret-fp-infra-creds.yaml b/deploy/secrets/external-secrets/externalsecret-fp-infra-creds.yaml index 86a5964..f9bde14 100644 --- a/deploy/secrets/external-secrets/externalsecret-fp-infra-creds.yaml +++ b/deploy/secrets/external-secrets/externalsecret-fp-infra-creds.yaml @@ -17,9 +17,14 @@ # needs Redis shouldn't receive the Postgres password, and vice versa — refine # with separate ExternalSecrets per store if you want stricter isolation). # -# SOURCES (both are JSON blobs Terraform writes; we extract named properties): -# - "/rds/postgres" (modules/rds) -> dsn, host, port +# SOURCES (JSON blobs; we extract named properties): +# - (created + rotated BY RDS) -> username, password +# Replace the placeholder with the `rds_master_user_secret_arn` TF output. +# - "/rds/postgres" (modules/rds, no password) -> host, port, +# dbname, sslmode # - "/elasticache/redis" (modules/elasticache)-> url, host, port, auth_token +# FP_DATABASE_URL is assembled by the template below, so the Postgres password +# never passes through Terraform state. # No password/token/URL value is ever written here — only the AWS key NAME and # the JSON property to pull. The plaintext lives in AWS, encrypted under KMS. # @@ -46,34 +51,58 @@ spec: creationPolicy: Owner template: type: Opaque + engineVersion: v2 + # With template.data set, ONLY these keys land in the Secret; the fetched + # values below (pg_*, redis_*) are template inputs. + data: + FP_DATABASE_URL: "postgres://{{ .pg_username | urlquery }}:{{ .pg_password | urlquery }}@{{ .pg_host }}:{{ .pg_port }}/{{ .pg_dbname }}?sslmode={{ .pg_sslmode }}" + POSTGRES_HOST: "{{ .pg_host }}" + POSTGRES_PORT: "{{ .pg_port }}" + FP_REDIS_URL: "{{ .redis_url }}" + REDIS_HOST: "{{ .redis_host }}" + REDIS_PORT: "{{ .redis_port }}" + REDIS_AUTH_TOKEN: "{{ .redis_auth_token }}" data: - # ---- Postgres (RDS module: "/rds/postgres") -------------------- - - secretKey: FP_DATABASE_URL + # ---- Postgres credentials (RDS-managed secret, rotated by RDS) ---------- + - secretKey: pg_username remoteRef: - key: "forgepoint-dev/rds/postgres" # PLACEHOLDER prefix - property: dsn - - secretKey: POSTGRES_HOST + key: "" # PLACEHOLDER: TF output rds_master_user_secret_arn + property: username + - secretKey: pg_password remoteRef: - key: "forgepoint-dev/rds/postgres" + key: "" + property: password + # ---- Postgres connection info (RDS module: "/rds/postgres") ----- + - secretKey: pg_host + remoteRef: + key: "forgepoint-dev/rds/postgres" # PLACEHOLDER prefix property: host - - secretKey: POSTGRES_PORT + - secretKey: pg_port remoteRef: key: "forgepoint-dev/rds/postgres" property: port + - secretKey: pg_dbname + remoteRef: + key: "forgepoint-dev/rds/postgres" + property: dbname + - secretKey: pg_sslmode + remoteRef: + key: "forgepoint-dev/rds/postgres" + property: sslmode # ---- Redis (ElastiCache module: "/elasticache/redis") ---------- - - secretKey: FP_REDIS_URL + - secretKey: redis_url remoteRef: key: "forgepoint-dev/elasticache/redis" # PLACEHOLDER prefix property: url - - secretKey: REDIS_HOST + - secretKey: redis_host remoteRef: key: "forgepoint-dev/elasticache/redis" property: host - - secretKey: REDIS_PORT + - secretKey: redis_port remoteRef: key: "forgepoint-dev/elasticache/redis" property: port - - secretKey: REDIS_AUTH_TOKEN + - secretKey: redis_auth_token remoteRef: key: "forgepoint-dev/elasticache/redis" property: auth_token diff --git a/deploy/terraform/.gitignore b/deploy/terraform/.gitignore index 694198f..3b98a5c 100644 --- a/deploy/terraform/.gitignore +++ b/deploy/terraform/.gitignore @@ -1,9 +1,9 @@ # ============================================================================ # Terraform-specific ignores (scoped to deploy/terraform) # ============================================================================ -# The repo root .gitignore already excludes *.tfstate, *.tfstate.backup, -# .terraform/, and .terraform.lock.hcl. This file adds the bits specific to this -# tree and documents WHY each is ignored. +# The repo root .gitignore already excludes *.tfstate, *.tfstate.backup and +# .terraform/. This file adds the bits specific to this tree and documents WHY +# each is ignored. # Real per-env variable files. terraform.tfvars.example IS committed (the # template); the filled-in terraform.tfvars may contain environment-specific @@ -18,7 +18,12 @@ terraform.tfvars crash.log *.tfplan -# NOTE on lock files: .terraform.lock.hcl is ignored by the ROOT .gitignore in -# this repo. In a production setup you would normally COMMIT the lock file so -# every machine/CI run resolves identical provider checksums. If you adopt that, -# add `!**/.terraform.lock.hcl` here to un-ignore it. +# NOTE on lock files: .terraform.lock.hcl IS committed, one per root module +# (environments//, bootstrap/). It records the exact provider version +# `terraform init` selected inside the ~> constraints AND the checksums of that +# build for every platform we run on, so a provider binary that does not match +# the recorded hash fails `init` instead of running. After changing a +# constraint, regenerate it with: +# terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 +# (linux_amd64 = CI runners, darwin_arm64 = maintainer laptops.) Dependabot's +# terraform ecosystem updates the lock file together with the constraint. diff --git a/deploy/terraform/README.md b/deploy/terraform/README.md index 81c98cc..68276f7 100644 --- a/deploy/terraform/README.md +++ b/deploy/terraform/README.md @@ -28,7 +28,9 @@ deploy/terraform/ │ ├── s3/ ← model-artifacts bucket: versioned, SSE-KMS, public-access blocked │ ├── ecr/ ← one repo per service, scan-on-push, immutable tags, lifecycle │ ├── iam/ ← IRSA roles per workload, least-privilege, bound to K8s SAs via OIDC -│ └── state-backend/ ← S3 (versioned/SSE/locked) + DynamoDB lock table FOR the remote state +│ ├── github-oidc/ ← opt-in keyless GitHub Actions role that can push to ECR (main + v* tags only) +│ └── state-backend/ ← S3 bucket (versioned, SSE-KMS CMK, TLS-only, prevent_destroy) FOR remote state +├── bootstrap/ ← the one root with LOCAL state: applies state-backend once per account └── environments/ └── dev/ ← composes the modules with small/cheap defaults ├── versions.tf (symlink-style copy of root pins — kept per-env so each env is hermetic) @@ -41,33 +43,61 @@ deploy/terraform/ ``` `staging/` and `prod/` are produced by **copying `dev/` and tuning the variables** (bigger -instances, `multi_az = true`, `deletion_protection = true`, restricted API CIDRs). The -modules themselves do not change between environments — that's the whole point of the split. +instances, `multi_az = true`, restricted API CIDRs). Destructive settings (deletion +protection off, no final snapshot, ECR force-delete, immediate secret deletion) are +variables whose **defaults are the safe values**; only the dev `terraform.tfvars.example` +turns them on, so a copied directory starts safe. The modules themselves do not change +between environments — that's the whole point of the split. ## Bootstrapping order (chicken-and-egg with remote state) -The `state-backend` module creates the S3 bucket + DynamoDB table that the *other* stacks -store their state in — but Terraform needs a backend before it can store state. Resolve it -the standard way: +The `state-backend` module creates the S3 bucket + KMS key that the *other* stacks store +their state in — but Terraform needs a backend before it can store state. Resolve it the +standard way: -1. **First run, local state:** apply `state-backend` (from a tiny bootstrap stack or by - targeting it) with the default *local* backend. This creates the bucket + lock table. -2. **Migrate:** fill in `backend.tf` with that bucket/table and run - `terraform init -migrate-state` to push local state into S3. +1. **First run, local state:** `terraform -chdir=deploy/terraform/bootstrap init && … apply`. + The bootstrap root calls `state-backend` with the default *local* backend and creates + the bucket (versioned, SSE-KMS with its own CMK, TLS-only policy, public access blocked, + `prevent_destroy`) and the key (rotated, `prevent_destroy`, 30-day deletion window). + Set `state_access_principal_arns` to restrict state reads/writes to the roles that run + Terraform. +2. **Migrate:** copy the bootstrap `backend_config` output into `environments//backend.tf` + (or a gitignored `*.backend.hcl`) and run `terraform init -migrate-state`. 3. Everything afterwards (network/eks/rds/…) uses the S3 backend from the start. +Locking uses the S3-native lock file (`use_lockfile = true`, Terraform ≥ 1.11). There is no +DynamoDB lock table: DynamoDB locking is deprecated in the S3 backend. + `dev/backend.tf` ships **commented out** so `terraform init -backend=false` works offline -(no AWS creds, no real bucket) for `validate`/`plan` in CI and for graders. +(no AWS creds, no real bucket). CI (`.github/workflows/terraform.yml`) runs `fmt -check`, +`init -backend=false` and `validate` on every root; it does **not** plan, because CI has +no AWS credentials and nothing has been applied. ## Conventions enforced everywhere - **Tagging:** every taggable resource carries `Project=forgepoint`, `Environment=`, `ManagedBy=terraform` (applied once via the provider `default_tags` — see `dev/providers.tf`). -- **No hardcoded secrets / account IDs / regions:** all of these are variables. Database and - Redis credentials are **generated by Terraform and stored in AWS Secrets Manager**, never - passed in as `.tfvars` and never printed (outputs expose only ARNs, marked `sensitive`). +- **No hardcoded secrets / account IDs / regions:** all of these are variables. No credential + is ever passed in as `.tfvars`. + - The **Postgres master password** is generated, stored and rotated by **RDS itself** + (`manage_master_user_password`). It never passes through Terraform, so it is not in + state. + - The **Redis AUTH token** is generated by Terraform (`random_password`) and stored in + Secrets Manager. ElastiCache has no managed-password option, and AWS provider 5.x has + no write-only `auth_token`, so **this token is in state**. That is one reason the state + bucket is encrypted with a customer-managed KMS key. It is readable only by the roles + in `state_access_principal_arns` **once you set that variable**: it defaults to `[]`, + and an empty list adds no deny statement, so until then any principal in the account + with S3 and KMS read access can read state (see `modules/state-backend`). + - **Outputs** expose ARNs and endpoints only. None is marked `sensitive`, because none is + a secret, and `sensitive` only hides a value from CLI output: it does not keep it out + of state. (An earlier version of this README said the outputs were marked sensitive; + they never were.) - **Encryption everywhere:** KMS for EKS secrets, RDS storage, S3 (SSE-KMS), ElastiCache at-rest + in-transit; S3 + RDS + ElastiCache are all private-subnet only. +- **Cost guardrail:** each environment has a monthly `aws_budgets_budget` (default $250) that + emails `budget_alert_emails` at 50/80/100% of actual spend and 100% of forecast. The + variable has no default, so an environment cannot be applied without a recipient. - **Private-by-default networking:** workloads live in private subnets; only the NAT gateway and (optionally) the EKS public API endpoint touch the internet. S3/ECR reach via VPC endpoints to cut NAT data-processing cost. diff --git a/deploy/terraform/backend.tf b/deploy/terraform/backend.tf index 060255c..6c5fb18 100644 --- a/deploy/terraform/backend.tf +++ b/deploy/terraform/backend.tf @@ -9,17 +9,19 @@ # WHY remote state at all: # - SHARED + LOCKED: a team (or CI) runs Terraform from many machines. Local # state on one laptop is a single point of truth/failure and invites two -# people applying at once and corrupting state. S3 stores it centrally; -# DynamoDB provides a LOCK so only one apply mutates state at a time. +# people applying at once and corrupting state. S3 stores it centrally, and +# an S3-native lock file (use_lockfile, Terraform >= 1.11) makes sure only +# one apply mutates state at a time. (DynamoDB locking is deprecated.) # - DURABLE + VERSIONED: the S3 bucket has versioning on (see the state-backend # module), so a botched apply can be rolled back to a prior state object. -# - ENCRYPTED: state contains sensitive material (resource IDs, sometimes -# secret values), so the bucket is SSE-encrypted and the backend sets -# `encrypt = true`. +# - ENCRYPTED: state contains sensitive material (resource IDs and, here, the +# Redis AUTH token), so the bucket is SSE-KMS with a customer-managed key and +# the backend sets `encrypt = true` + `kms_key_id`. # -# CHICKEN-AND-EGG: the S3 bucket + DynamoDB table that back this state are -# themselves created by modules/state-backend. Bootstrap once with LOCAL state, -# then `terraform init -migrate-state` into S3. See README.md "Bootstrapping". +# CHICKEN-AND-EGG: the S3 bucket + KMS key that back this state are themselves +# created by the bootstrap root (deploy/terraform/bootstrap, which calls +# modules/state-backend) with LOCAL state; then `terraform init -migrate-state` +# moves each environment into S3. See README.md "Bootstrapping order". # # This block is intentionally COMMENTED OUT here. The root dir is not a rootmodule # you `apply` — it just holds shared versions.tf + this reference. The real, @@ -28,10 +30,11 @@ # # terraform { # backend "s3" { -# bucket = "forgepoint-tfstate-" # from state-backend module output -# key = "/terraform.tfstate" # per-env key (dev/, staging/, prod/) -# region = "us-east-1" # bucket region (not necessarily infra region) -# dynamodb_table = "forgepoint-tflock" # lock table from state-backend module -# encrypt = true # encrypt the state object at rest +# bucket = "forgepoint-tfstate-" # bootstrap output state_bucket_name +# key = "/terraform.tfstate" # per-env key (dev/, staging/, prod/) +# region = "us-east-1" # bucket region (not necessarily infra region) +# encrypt = true # encrypt the state object at rest... +# kms_key_id = "" # ...with the state CMK +# use_lockfile = true # S3-native lock (Terraform >= 1.11) # } # } diff --git a/deploy/terraform/bootstrap/.terraform.lock.hcl b/deploy/terraform/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..13a46db --- /dev/null +++ b/deploy/terraform/bootstrap/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.60" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/deploy/terraform/bootstrap/main.tf b/deploy/terraform/bootstrap/main.tf new file mode 100644 index 0000000..4b69bf5 --- /dev/null +++ b/deploy/terraform/bootstrap/main.tf @@ -0,0 +1,30 @@ +# ============================================================================ +# bootstrap — creates the remote-state backend for every other root +# ============================================================================ +# This is the ONE root that starts with local state, because the bucket it +# creates is where everything else keeps its state. Run it once per account: +# +# terraform -chdir=deploy/terraform/bootstrap init +# terraform -chdir=deploy/terraform/bootstrap apply +# +# Then copy the `backend_config` output into environments//backend.tf (or +# a gitignored *.backend.hcl passed with -backend-config) and run +# `terraform init -migrate-state` there. Optionally migrate this root's own +# state into the bucket too (key = "bootstrap/terraform.tfstate"): the bucket +# and key have prevent_destroy, so losing the local file is recoverable with +# `terraform import`, but keeping it remote is tidier. +# +# Everything here is protected from `terraform destroy` (prevent_destroy on the +# bucket and the KMS key). Tearing the backend down is a deliberate, manual act. +# ============================================================================ + +data "aws_caller_identity" "current" {} + +module "state_backend" { + source = "../modules/state-backend" + + # Bucket names are global, so suffix with the account id (read at plan time, + # never hard-coded). + bucket_name = "forgepoint-tfstate-${data.aws_caller_identity.current.account_id}" + state_access_principal_arns = var.state_access_principal_arns +} diff --git a/deploy/terraform/bootstrap/outputs.tf b/deploy/terraform/bootstrap/outputs.tf new file mode 100644 index 0000000..3520678 --- /dev/null +++ b/deploy/terraform/bootstrap/outputs.tf @@ -0,0 +1,21 @@ +output "state_bucket_name" { + description = "The remote-state bucket." + value = module.state_backend.state_bucket_name +} + +output "kms_key_arn" { + description = "The KMS key that encrypts state objects." + value = module.state_backend.kms_key_arn +} + +output "backend_config" { + description = "Backend settings for environments//backend.tf (replace ). Not secret: bucket and key names only." + value = <<-EOT + bucket = "${module.state_backend.state_bucket_name}" + key = "/terraform.tfstate" + region = "${var.aws_region}" + encrypt = true + kms_key_id = "${module.state_backend.kms_key_arn}" + use_lockfile = true + EOT +} diff --git a/deploy/terraform/bootstrap/providers.tf b/deploy/terraform/bootstrap/providers.tf new file mode 100644 index 0000000..02b6461 --- /dev/null +++ b/deploy/terraform/bootstrap/providers.tf @@ -0,0 +1,12 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "forgepoint" + Environment = "shared" + ManagedBy = "terraform" + Stack = "bootstrap" + } + } +} diff --git a/deploy/terraform/bootstrap/variables.tf b/deploy/terraform/bootstrap/variables.tf new file mode 100644 index 0000000..a18d6c3 --- /dev/null +++ b/deploy/terraform/bootstrap/variables.tf @@ -0,0 +1,11 @@ +variable "aws_region" { + description = "Region for the state bucket and its KMS key. Every environment's backend block must name the same region." + type = string + default = "us-east-1" +} + +variable "state_access_principal_arns" { + description = "IAM principals (wildcards allowed) allowed to read/write state objects. Non-empty = everyone else is denied by the bucket policy. Example: [\"arn:aws:iam:::role/terraform-*\", \"arn:aws:iam:::user/\"]. Include whatever identity runs this bootstrap if you plan to migrate its own state into the bucket." + type = list(string) + default = [] +} diff --git a/deploy/terraform/bootstrap/versions.tf b/deploy/terraform/bootstrap/versions.tf new file mode 100644 index 0000000..f6b6e9f --- /dev/null +++ b/deploy/terraform/bootstrap/versions.tf @@ -0,0 +1,20 @@ +# ============================================================================ +# bootstrap — VERSION PINS +# ============================================================================ +# Same AWS provider constraint as environments/*/versions.tf. The committed +# .terraform.lock.hcl freezes the exact build and its checksums. +# +# required_version >= 1.11: the environments' S3 backend uses use_lockfile +# (S3-native state locking), which is GA from Terraform 1.11. +# ============================================================================ + +terraform { + required_version = ">= 1.11.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.60" + } + } +} diff --git a/deploy/terraform/environments/dev/.terraform.lock.hcl b/deploy/terraform/environments/dev/.terraform.lock.hcl new file mode 100644 index 0000000..50e3b26 --- /dev/null +++ b/deploy/terraform/environments/dev/.terraform.lock.hcl @@ -0,0 +1,91 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.60" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.38.0" + constraints = "~> 2.31" + hashes = [ + "h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=", + "h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=", + "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", + "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", + "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", + "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", + "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", + "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", + "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", + "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", + "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", + "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + constraints = "~> 3.6" + hashes = [ + "h1:PlW+UZ4EElQF3NQwf41KQwavFujab3Czc51zu9dyVM8=", + "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.4.2" + constraints = "~> 4.0" + hashes = [ + "h1:/RQKd6LussdsR99zSuIYdV4e3JrV4l0stQ5Vb1qnbWo=", + "h1:cMw+jmw1zbZ2Tgl2zwm1pU3S5jySk9i2W3ZRzu7YaVI=", + "zh:034fdb3f45f0fe3e6fc905bfe451eab8f7055c253f27c787470f550621993c4b", + "zh:16a5d4055a8e2f6d35e139d6c4c23fb8c53d9f6105b8d509030bb9720b301e02", + "zh:2d4009339fcbb2c9c2fe0eeb1cd9154d8bc06794c1c34265a32f97a5a56001f7", + "zh:3ee85048c51bbc06c7327c5aadf621e360d87fa39fbf7910eabb2a4e9edd98d4", + "zh:4d5ba1e4e9c5054ff123286a53d8d3e082818f20162f5cbdd3b0c634fab0b3f6", + "zh:4e55f5c32d9e2e87eec31a874535a4916ba5add06c1123dd4ae5d5c0909f785d", + "zh:8cd5cd017c2e8c93f9beb7f54776480210f22b2498a8fdf90dc01dfdf0930ba2", + "zh:a3996a6ffa641d1670cebde54421441f858104702a3c512c85b832eab3eaf564", + "zh:ab88ea84cef01a0cbdc8f65fb67fc6e840ad1475844a0f4995a7559816bc592e", + "zh:aef59a5322085ec071013fa79621ec75716846ce25f98fb181233df657e60d03", + "zh:dfb0f5cfe5b139cdddb941baae0d40f793c7080546f7a46988c5df2644c010bb", + "zh:e5b9b260e468fed152a31a7a815eaf4cb048e579857d7da883cdf6fa012415f9", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/deploy/terraform/environments/dev/backend.tf b/deploy/terraform/environments/dev/backend.tf index 43cd794..6f54de2 100644 --- a/deploy/terraform/environments/dev/backend.tf +++ b/deploy/terraform/environments/dev/backend.tf @@ -2,18 +2,25 @@ # dev — REMOTE STATE BACKEND # ============================================================================ # State for the DEV environment lives under its own key in the shared state -# bucket (created by modules/state-backend). Separate key per env => a dev -# destroy can never touch staging/prod state. +# bucket (created by deploy/terraform/bootstrap via modules/state-backend). +# Separate key per env => a dev destroy can never touch staging/prod state. # # WHY values are commented + placeholder: -# - The bucket/table don't exist until state-backend is bootstrapped (see -# README "Bootstrapping order"). Until then we run with LOCAL state. -# - Graders + CI validate OFFLINE with `terraform init -backend=false`, which -# SKIPS this block entirely (no AWS creds, no real bucket needed). That is the -# command in the task's VALIDATE step and the reason this stays commented. +# - The bucket and key don't exist until the bootstrap root has been applied +# (see README "Bootstrapping order"). Until then we run with LOCAL state. +# - CI (.github/workflows/terraform.yml) validates OFFLINE with +# `terraform init -backend=false`, which SKIPS this block entirely (no AWS +# creds, no real bucket). CI does not plan: there are no AWS credentials in +# CI and this stack has never been applied. # -# TO GO LIVE: uncomment, replace with your account (or use the -# state_bucket_name output from state-backend), then `terraform init -migrate-state`. +# TO GO LIVE: uncomment and fill in from the bootstrap root's `backend_config` +# output (or put the same lines in a gitignored dev.backend.hcl and pass +# -backend-config=dev.backend.hcl), then `terraform init -migrate-state`. +# +# LOCKING: use_lockfile = true (S3-native lock file, GA in Terraform 1.11). The +# old dynamodb_table setting is deprecated and the module no longer creates a +# lock table. kms_key_id makes each state write use the state CMK; without it +# the backend asks S3 for SSE-S3 on every write. # Backend config can't use variables/interpolation (a Terraform limitation — the # backend is initialized before the rest of the config is evaluated), so these are # literal strings or supplied via `-backend-config=...` flags / a *.hcl file. @@ -21,10 +28,11 @@ terraform { backend "s3" { - # bucket = "forgepoint-tfstate-" - # key = "dev/terraform.tfstate" - # region = "us-east-1" - # dynamodb_table = "forgepoint-tflock" - # encrypt = true + # bucket = "forgepoint-tfstate-" + # key = "dev/terraform.tfstate" + # region = "us-east-1" + # encrypt = true + # kms_key_id = "" + # use_lockfile = true } } diff --git a/deploy/terraform/environments/dev/main.tf b/deploy/terraform/environments/dev/main.tf index bd215d6..1917f0b 100644 --- a/deploy/terraform/environments/dev/main.tf +++ b/deploy/terraform/environments/dev/main.tf @@ -17,6 +17,8 @@ # │ │ # iam ◄── consumes EKS OIDC + S3 bucket ARN + secret ARNs ───────┘ │ # └── builds IRSA roles bound to per-service ServiceAccounts ◄───┘ +# +# budget ── monthly cost budget with email alerts (account-wide) # ---------------------------------------------------------------------------- locals { @@ -81,8 +83,11 @@ module "rds" { multi_az = var.rds_multi_az deletion_protection = var.rds_deletion_protection backup_retention_days = var.rds_backup_retention_days - # Dev throwaway: skip the final snapshot so teardown is instant + free. - skip_final_snapshot = true + # Destructive settings come from variables with SAFE defaults; the dev + # teardown values live in terraform.tfvars.example. (They used to be + # hard-coded here, so a copy of this directory for prod inherited them.) + skip_final_snapshot = var.rds_skip_final_snapshot + secret_recovery_window_days = var.secret_recovery_window_days } # ---------------------------------------------------------------------------- @@ -100,20 +105,21 @@ module "elasticache" { node_type = var.redis_node_type num_cache_nodes = var.redis_num_nodes automatic_failover = var.redis_automatic_failover + + secret_recovery_window_days = var.secret_recovery_window_days } # ---------------------------------------------------------------------------- # (5) S3 — model-artifacts bucket # ---------------------------------------------------------------------------- -# Bucket names are globally unique, so suffix with the account id. We reuse the -# RDS KMS key for SSE-KMS to avoid spinning up yet another CMK in dev; prod could -# pass a dedicated artifacts key. +# Bucket names are globally unique, so suffix with the account id. The module +# creates a dedicated CMK for the bucket. (It used to reuse the RDS key, which +# gave every artifact reader kms:Decrypt on the key that wraps the DB secrets.) module "s3" { source = "../../modules/s3" name_prefix = local.name_prefix bucket_name = "${local.name_prefix}-model-artifacts-${data.aws_caller_identity.current.account_id}" - kms_key_arn = module.rds.kms_key_arn } # ---------------------------------------------------------------------------- @@ -123,8 +129,9 @@ module "ecr" { source = "../../modules/ecr" name_prefix = local.name_prefix - # Dev: allow destroy to remove repos with images, mutable-tag off (immutable). - force_delete = true + # Images stay immutable (module default). force_delete comes from a variable + # with a safe default; dev opts in via tfvars so destroy can remove repos. + force_delete = var.ecr_force_delete } # ---------------------------------------------------------------------------- @@ -135,9 +142,11 @@ module "ecr" { # Helm chart fullnames (e.g. fp-registry) in the fp-system namespace. # # Roles modeled here (extend as services gain AWS needs): -# - registry : read/write model artifacts in S3 + read its DB secret -# - model-serving : READ model artifacts from S3 (pulls weights) + read DB? no. -# serving holds models in-memory; it only needs S3 read. +# - registry : read/write model artifacts in S3 +# - model-serving : READ model artifacts from S3 (pulls weights) +# NOTE: no service code uses S3 yet (no AWS SDK in any go.mod; model-serving +# fetches file:// and http(s):// URLs). These two roles are the intended +# shape; they grant nothing until a ServiceAccount is annotated with them. # - external-secrets: the External Secrets Operator's controller SA — reads the # RDS + Redis secrets from Secrets Manager and projects them # into K8s Secrets for every service. Central, so each app @@ -145,10 +154,11 @@ module "ecr" { module "iam" { source = "../../modules/iam" - name_prefix = local.name_prefix - oidc_provider_arn = module.eks.oidc_provider_arn - oidc_provider_url = module.eks.oidc_provider_url - artifacts_bucket_arn = module.s3.bucket_arn + name_prefix = local.name_prefix + oidc_provider_arn = module.eks.oidc_provider_arn + oidc_provider_url = module.eks.oidc_provider_url + artifacts_bucket_arn = module.s3.bucket_arn + artifacts_kms_key_arn = module.s3.kms_key_arn roles = { # Model Registry: writes artifacts on RegisterVersion, reads on serve-prep. @@ -157,9 +167,9 @@ module "iam" { service_account_name = "fp-registry" s3_artifact_access = "readwrite" secret_arns = [] - # The artifacts bucket uses the RDS CMK for SSE-KMS (see module "s3"), so - # reading/writing objects needs kms:Decrypt on that key. - kms_key_arns = [module.rds.kms_key_arn] + # KMS for the artifacts key comes from artifacts_kms_key_arn above; this + # role needs no other key. + kms_key_arns = [] } # Model Serving: pulls (reads) model weights from S3 on startup. Read-only. @@ -168,7 +178,7 @@ module "iam" { service_account_name = "fp-model-serving" s3_artifact_access = "read" secret_arns = [] - kms_key_arns = [module.rds.kms_key_arn] + kms_key_arns = [] } # External Secrets Operator controller: the ONE workload allowed to read the @@ -180,6 +190,8 @@ module "iam" { s3_artifact_access = "" secret_arns = [ module.rds.credentials_secret_arn, + # The RDS-managed master password (username + password JSON). + module.rds.master_user_secret_arn, module.elasticache.credentials_secret_arn, ] kms_key_arns = [ @@ -189,3 +201,65 @@ module "iam" { } } } + +# ---------------------------------------------------------------------------- +# (8) BUDGET — a cost guardrail with email alerts +# ---------------------------------------------------------------------------- +# This env runs an EKS control plane, a NAT gateway, interface endpoints, RDS +# and ElastiCache around the clock, so a forgotten `terraform apply` costs +# money every hour. Nothing else in this stack would ever tell you. +# +# Scope: the WHOLE ACCOUNT's monthly cost (no cost filter). Filtering on the +# Project tag would read $0 until that tag is activated as a cost allocation +# tag in the Billing console, which is exactly the silent failure a budget +# exists to prevent. If dev and prod share an account, give prod its own +# budget and raise this one, or switch to a tag filter after activating it. +resource "aws_budgets_budget" "monthly" { + name = "${local.name_prefix}-monthly" + budget_type = "COST" + limit_amount = tostring(var.budget_monthly_limit_usd) + limit_unit = "USD" + time_unit = "MONTHLY" + + dynamic "notification" { + for_each = [50, 80, 100] + content { + comparison_operator = "GREATER_THAN" + threshold = notification.value + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = var.budget_alert_emails + } + } + + # Forecast alert: fires early in the month when the run rate says you will + # blow the budget, before the money is actually spent. + notification { + comparison_operator = "GREATER_THAN" + threshold = 100 + threshold_type = "PERCENTAGE" + notification_type = "FORECASTED" + subscriber_email_addresses = var.budget_alert_emails + } +} + +# ---------------------------------------------------------------------------- +# (9) CI IDENTITY — GitHub Actions → ECR push via OIDC (OPT-IN, off by default) +# ---------------------------------------------------------------------------- +# The ECR repos above had no pusher: CI publishes to GHCR with GITHUB_TOKEN and +# there was no AWS identity for it at all. This role is the keyless way to add +# one: only workflow runs on main or a v* tag can assume it (never a PR run), +# and it can push only to this env's ECR repos. Off by default because no +# workflow pushes to ECR yet and an unused push role is surface for nothing. +# Turn it on with enable_github_ecr_push = true, then store the role_arn output +# as the repository variable AWS_ROLE_ARN. +module "github_oidc" { + source = "../../modules/github-oidc" + count = var.enable_github_ecr_push ? 1 : 0 + + name_prefix = local.name_prefix + create_oidc_provider = var.create_github_oidc_provider + oidc_provider_arn = var.github_oidc_provider_arn + allowed_subjects = var.github_oidc_subjects + ecr_repository_arns = values(module.ecr.repository_arns) +} diff --git a/deploy/terraform/environments/dev/outputs.tf b/deploy/terraform/environments/dev/outputs.tf index 0aa6193..a1f03b8 100644 --- a/deploy/terraform/environments/dev/outputs.tf +++ b/deploy/terraform/environments/dev/outputs.tf @@ -41,10 +41,15 @@ output "rds_endpoint" { } output "rds_credentials_secret_arn" { - description = "Secrets Manager ARN holding the Postgres DSN/credentials. External Secrets Operator (under the external_secrets IRSA role) syncs this into K8s." + description = "Secrets Manager ARN holding the Postgres connection info (host, port, dbname, username; no password). External Secrets Operator reads it to build the DSN." value = module.rds.credentials_secret_arn } +output "rds_master_user_secret_arn" { + description = "Secrets Manager ARN of the RDS-managed (and rotated) master password. Put this ARN in the ExternalSecret manifests under deploy/secrets/external-secrets (placeholder )." + value = module.rds.master_user_secret_arn +} + output "redis_endpoint" { description = "Redis primary endpoint. Apps read the rediss:// URL from redis_credentials_secret_arn." value = module.elasticache.primary_endpoint @@ -66,6 +71,11 @@ output "ecr_repository_urls" { value = module.ecr.repository_urls } +output "github_ecr_push_role_arn" { + description = "ARN of the GitHub Actions ECR push role (null unless enable_github_ecr_push = true). Store it as the repository variable AWS_ROLE_ARN." + value = var.enable_github_ecr_push ? module.github_oidc[0].role_arn : null +} + # ---- IRSA ------------------------------------------------------------------ output "irsa_role_arns" { description = "Map of logical workload → IAM role ARN. Annotate each K8s ServiceAccount with eks.amazonaws.com/role-arn = to grant the pod its scoped AWS access without static keys." diff --git a/deploy/terraform/environments/dev/terraform.tfvars.example b/deploy/terraform/environments/dev/terraform.tfvars.example index 434b2a6..ee86cc6 100644 --- a/deploy/terraform/environments/dev/terraform.tfvars.example +++ b/deploy/terraform/environments/dev/terraform.tfvars.example @@ -1,10 +1,12 @@ # ============================================================================ # dev — EXAMPLE TFVARS # ============================================================================ -# Copy to terraform.tfvars (gitignored) and tune. Every value here already has a -# sensible dev default in variables.tf, so this file is OPTIONAL — it exists to -# show the knobs and document how prod would differ. There are NO secrets here: -# DB/Redis credentials are generated by Terraform into Secrets Manager. +# Copy to terraform.tfvars (gitignored) and tune. Sizing values match the +# defaults in variables.tf. The DESTRUCTIVE switches (deletion protection off, +# no final snapshot, force-delete, immediate secret deletion) do NOT: their +# defaults are the safe values, and this file opts a throwaway dev env into +# them. There are NO secrets here: the Postgres password is managed by RDS and +# the Redis token is generated by Terraform into Secrets Manager. # ---------------------------------------------------------------------------- environment = "dev" @@ -16,10 +18,18 @@ az_count = 3 single_nat_gateway = true # PROD: false (one NAT per AZ for egress resilience) # ---- EKS ------------------------------------------------------------------- -cluster_version = "1.30" -eks_public_access = true # PROD: false (reach the API via VPN/bastion only) -# SECURITY: replace with your office/VPN egress CIDR. 0.0.0.0/0 = open to the world. -eks_public_access_cidrs = ["0.0.0.0/0"] +cluster_version = "1.36" +# Kubernetes API endpoint. Same secure default as variables.tf: private only, +# reached from inside the VPC (bastion / VPN / SSM port-forward). +eks_public_access = false +eks_public_access_cidrs = [] +# Need kubectl from a laptop on a throwaway cluster? Enable the public endpoint +# AND restrict it to your own egress IP (curl -s https://checkip.amazonaws.com): +# eks_public_access = true +# eks_public_access_cidrs = ["203.0.113.10/32"] +# Never 0.0.0.0/0: that puts the API server on the internet, guarded only by +# authentication. (Trivy does not scan *.tfvars.example files, so nothing in CI +# would have caught this file doing exactly that.) node_instance_types = ["t3.medium"] node_desired_size = 2 @@ -31,7 +41,8 @@ node_capacity_type = "ON_DEMAND" # DEV money-saver: "SPOT" (~70% cheaper) rds_instance_class = "db.t3.micro" rds_engine_version = "16.4" rds_multi_az = false # PROD: true -rds_deletion_protection = false # PROD: true +rds_deletion_protection = false # PROD: true (the variable default) +rds_skip_final_snapshot = true # PROD: false (the variable default) rds_backup_retention_days = 7 # PROD: 14-35 # ---- ElastiCache (Redis) --------------------------------------------------- @@ -39,3 +50,14 @@ redis_node_type = "cache.t3.micro" redis_engine_version = "7.1" redis_num_nodes = 1 # PROD: >= 2 redis_automatic_failover = false # PROD: true (needs redis_num_nodes >= 2) + +# ---- Cost guardrail (budget_alert_emails is REQUIRED) ----------------------- +budget_monthly_limit_usd = 250 +budget_alert_emails = ["you@example.com"] + +# ---- Throwaway-env teardown switches ---------------------------------------- +# These make `terraform destroy` fast and complete for a disposable dev env. The +# variable defaults are the SAFE values; delete these lines for anything that +# holds data you care about. +secret_recovery_window_days = 0 # PROD: 7-30 (default 30) +ecr_force_delete = true # PROD: false (the default) diff --git a/deploy/terraform/environments/dev/variables.tf b/deploy/terraform/environments/dev/variables.tf index be35ea8..8c8bfc4 100644 --- a/deploy/terraform/environments/dev/variables.tf +++ b/deploy/terraform/environments/dev/variables.tf @@ -1,9 +1,11 @@ # ============================================================================ # dev — ENVIRONMENT VARIABLES # ============================================================================ -# Every knob the modules expose is surfaced here with a DEV-appropriate default -# (small + cheap). staging/prod copy this directory and override via *.tfvars — -# the modules don't change between environments, only these values do. +# Every knob the modules expose is surfaced here. Sizing defaults are DEV-sized +# (small + cheap). Anything DESTRUCTIVE (deletion protection off, no final +# snapshot, force-delete, immediate secret deletion) defaults to the SAFE value +# and the dev env opts in through terraform.tfvars, so a copy of this directory +# for staging/prod starts safe even if nobody copies the dev tfvars. # # Nothing secret lives here: DB/Redis credentials are GENERATED by Terraform and # stored in Secrets Manager (see the rds/elasticache modules). No account IDs are @@ -44,9 +46,9 @@ variable "single_nat_gateway" { # ---- EKS ------------------------------------------------------------------- variable "cluster_version" { - description = "EKS Kubernetes version." + description = "EKS Kubernetes version. Keep it in EKS standard support (1.36 until 2 Aug 2027); 1.30 left extended support in July 2026 and can no longer be created." type = string - default = "1.30" + default = "1.36" } variable "eks_public_access" { @@ -111,7 +113,13 @@ variable "rds_multi_az" { } variable "rds_deletion_protection" { - description = "Block deletion of the DB. Dev: FALSE (teardown). Prod: TRUE." + description = "Block deletion of the DB. SAFE DEFAULT true. The dev tfvars sets false so the throwaway env can be torn down." + type = bool + default = true +} + +variable "rds_skip_final_snapshot" { + description = "Skip the final snapshot when the DB is deleted. SAFE DEFAULT false. The dev tfvars sets true so teardown is instant and free." type = bool default = false } @@ -122,6 +130,64 @@ variable "rds_backup_retention_days" { default = 7 } +# ---- CI identity (GitHub Actions OIDC -> ECR push), opt-in -------------------- +variable "enable_github_ecr_push" { + description = "Create the GitHub OIDC provider + ECR push role (modules/github-oidc). Default false: no workflow pushes to ECR yet." + type = bool + default = false +} + +variable "create_github_oidc_provider" { + description = "Create the account-wide token.actions.githubusercontent.com provider. Set false (and pass github_oidc_provider_arn) if the account already has one." + type = bool + default = true +} + +variable "github_oidc_provider_arn" { + description = "Existing GitHub OIDC provider ARN, used when create_github_oidc_provider = false." + type = string + default = "" +} + +variable "github_oidc_subjects" { + description = "OIDC sub patterns allowed to assume the ECR push role: the default branch and release tags of this repo only. This repo predates GitHub's 15 Jul 2026 switch to immutable sub claims for new/renamed/transferred repos; if it is ever renamed or transferred, the sub becomes repo:OWNER@OWNER_ID/REPO@REPO_ID:... and these must change." + type = list(string) + default = [ + "repo:abd-ulbasit/forgepoint:ref:refs/heads/main", + "repo:abd-ulbasit/forgepoint:ref:refs/tags/v*", + ] +} + +# ---- Cost guardrail --------------------------------------------------------- +variable "budget_monthly_limit_usd" { + description = "Monthly cost budget in USD. This env idles at roughly $200-250/month (EKS control plane, NAT gateway, two t3.medium nodes, interface endpoints, RDS, ElastiCache, KMS keys), so the default leaves a little headroom; alerts fire at 50/80/100% of actual spend and at 100% of forecast." + type = number + default = 250 +} + +variable "budget_alert_emails" { + description = "Email addresses that receive the budget alerts. REQUIRED, with no default: an environment that runs a NAT gateway and an EKS control plane must not be applyable without someone being told what it costs. AWS sends a confirmation email to each address once." + type = list(string) + + validation { + condition = length(var.budget_alert_emails) > 0 + error_message = "budget_alert_emails must list at least one address." + } +} + +# ---- Destructive teardown switches (safe defaults; dev opts in via tfvars) -- +variable "secret_recovery_window_days" { + description = "Recovery window for the Secrets Manager secrets Terraform creates (RDS connection info, Redis credentials): 7-30 days, or 0 to delete immediately. SAFE DEFAULT 30. The dev tfvars sets 0 so destroy + re-apply does not collide with a secret pending deletion." + type = number + default = 30 +} + +variable "ecr_force_delete" { + description = "Let `terraform destroy` delete ECR repositories that still hold images. SAFE DEFAULT false. The dev tfvars sets true." + type = bool + default = false +} + # ---- ElastiCache (Redis) --------------------------------------------------- variable "redis_node_type" { description = "ElastiCache node type. Dev: cache.t3.micro." diff --git a/deploy/terraform/environments/dev/versions.tf b/deploy/terraform/environments/dev/versions.tf index dfe67e9..7f2271a 100644 --- a/deploy/terraform/environments/dev/versions.tf +++ b/deploy/terraform/environments/dev/versions.tf @@ -11,7 +11,8 @@ # ============================================================================ terraform { - required_version = ">= 1.9.0, < 2.0.0" + # >= 1.11: the S3 backend's use_lockfile (backend.tf) is GA from 1.11. + required_version = ">= 1.11.0, < 2.0.0" required_providers { aws = { diff --git a/deploy/terraform/modules/ecr/variables.tf b/deploy/terraform/modules/ecr/variables.tf index 9a3565f..944abd5 100644 --- a/deploy/terraform/modules/ecr/variables.tf +++ b/deploy/terraform/modules/ecr/variables.tf @@ -12,7 +12,7 @@ variable "name_prefix" { } variable "service_names" { - description = "The services to create repositories for. Defaults to the full 10-service set from the design doc plus the BFF. Override to add/remove." + description = "The services to create repositories for. Defaults to every directory under services/ that has a Dockerfile (the same list the Security workflow builds). Keep the two in step when a service is added." type = list(string) default = [ "auth", @@ -26,6 +26,7 @@ variable "service_names" { "model-serving", "model-monitor", "bff", + "ai-gateway", ] } @@ -59,9 +60,9 @@ variable "max_tagged_images" { } variable "force_delete" { - description = "Allow `terraform destroy` to delete a repo that still contains images. TRUE in dev (tear it all down). FALSE in prod so you can't wipe release images by accident." + description = "Allow `terraform destroy` to delete a repo that still contains images. SAFE DEFAULT false, so release images cannot be wiped by accident. A throwaway dev env sets true in its tfvars." type = bool - default = true + default = false } variable "tags" { diff --git a/deploy/terraform/modules/eks/variables.tf b/deploy/terraform/modules/eks/variables.tf index 3724122..cf2b6d7 100644 --- a/deploy/terraform/modules/eks/variables.tf +++ b/deploy/terraform/modules/eks/variables.tf @@ -13,9 +13,9 @@ variable "name_prefix" { } variable "cluster_version" { - description = "Kubernetes minor version for the control plane (e.g. \"1.30\"). Pin it: EKS won't auto-upgrade the control plane, and node AMIs are chosen to match. Bump deliberately, one minor at a time." + description = "Kubernetes minor version for the control plane. Pin it and bump deliberately, one minor at a time. Stay inside EKS STANDARD support: past it, EKS bills extended support per cluster-hour, and at the end of extended support it upgrades the control plane for you. 1.36 is in standard support until 2 Aug 2027 (EKS calendar, checked Oct 2026). Managed node groups on 1.30+ default to AL2023 AMIs." type = string - default = "1.30" + default = "1.36" } variable "vpc_id" { diff --git a/deploy/terraform/modules/elasticache/main.tf b/deploy/terraform/modules/elasticache/main.tf index e5d559a..47784d6 100644 --- a/deploy/terraform/modules/elasticache/main.tf +++ b/deploy/terraform/modules/elasticache/main.tf @@ -74,7 +74,22 @@ resource "aws_security_group_rule" "redis_ingress" { # ---------------------------------------------------------------------------- # ElastiCache AUTH tokens have constraints: 16-128 chars, and only a limited # special-char set. We restrict override_special accordingly so the token is -# always accepted by ElastiCache. +# always accepted by ElastiCache. Several of these characters are not legal raw +# in a URL, so the `url` field below percent-encodes the token. +# KNOWN RESIDUAL RISK: this token is stored in Terraform STATE (random_password +# results, the replication group's auth_token and the secret_string below are +# all persisted). Unlike RDS there is no AWS-managed password for ElastiCache, +# and AWS provider 5.x has no write-only auth_token. Mitigations: the state +# bucket is SSE-KMS with a CMK, and restricted to the roles listed in +# state_access_principal_arns once that is set (it defaults to [], which adds +# no restriction; modules/state-backend). The longer-term fix is ElastiCache +# RBAC users with IAM authentication, which needs a client change (short-lived +# IAM auth tokens). +# +# DRIFT HAZARD: the replication group ignores auth_token changes (below) but the +# secret version does not. If the token is ever rotated outside Terraform, the +# next apply rewrites the secret with the OLD token and clients break. Rotate +# by changing it here (and applying), never out of band. resource "random_password" "auth_token" { length = 48 special = true @@ -135,7 +150,7 @@ resource "aws_secretsmanager_secret" "redis" { name = "${var.name_prefix}/elasticache/redis" description = "Forgepoint Redis auth token + connection info" kms_key_id = aws_kms_key.redis.arn - recovery_window_in_days = 0 + recovery_window_in_days = var.secret_recovery_window_days tags = var.tags } @@ -147,6 +162,11 @@ resource "aws_secretsmanager_secret_version" "redis" { auth_token = random_password.auth_token.result # rediss:// (note the double-s) = Redis over TLS, required because transit # encryption is on. The app uses this URL form instead of plain redis://. - url = "rediss://:${random_password.auth_token.result}@${aws_elasticache_replication_group.this.primary_endpoint_address}:${aws_elasticache_replication_group.this.port}" + # The token is percent-encoded: override_special includes # ^ < >, which + # Go's net/url (used by go-redis ParseURL) rejects in the userinfo part, so + # a raw token makes most generated URLs unparseable ("invalid FP_REDIS_URL" + # at boot). ParseURL decodes it back, so clients still send the real token. + # auth_token above stays raw for clients that build their own connection. + url = "rediss://:${urlencode(random_password.auth_token.result)}@${aws_elasticache_replication_group.this.primary_endpoint_address}:${aws_elasticache_replication_group.this.port}" }) } diff --git a/deploy/terraform/modules/elasticache/variables.tf b/deploy/terraform/modules/elasticache/variables.tf index f80227d..2903787 100644 --- a/deploy/terraform/modules/elasticache/variables.tf +++ b/deploy/terraform/modules/elasticache/variables.tf @@ -59,6 +59,17 @@ variable "snapshot_retention_days" { default = 1 } +variable "secret_recovery_window_days" { + description = "Days Secrets Manager keeps the deleted Redis credentials secret recoverable (7-30), or 0 to delete immediately. SAFE DEFAULT 30 (the AWS default). A throwaway dev env may set 0 so destroy + re-apply does not collide with a secret pending deletion." + type = number + default = 30 + + validation { + condition = var.secret_recovery_window_days == 0 || (var.secret_recovery_window_days >= 7 && var.secret_recovery_window_days <= 30) + error_message = "secret_recovery_window_days must be 0 or between 7 and 30." + } +} + variable "tags" { description = "Additional tags (Project/Environment/ManagedBy via provider default_tags)." type = map(string) diff --git a/deploy/terraform/modules/github-oidc/main.tf b/deploy/terraform/modules/github-oidc/main.tf new file mode 100644 index 0000000..e975967 --- /dev/null +++ b/deploy/terraform/modules/github-oidc/main.tf @@ -0,0 +1,102 @@ +# ============================================================================ +# github-oidc — a short-lived, keyless identity for GitHub Actions to push to ECR +# ============================================================================ +# HOW IT WORKS: a workflow job with `permissions: id-token: write` asks GitHub +# for an OIDC token (issuer https://token.actions.githubusercontent.com) and +# exchanges it with sts:AssumeRoleWithWebIdentity for this role (e.g. via +# aws-actions/configure-aws-credentials with role-to-assume: vars.AWS_ROLE_ARN). +# STS checks the token against the trust policy below, so no AWS access key is +# ever stored in GitHub. +# +# THE LOCK is the `sub` claim. With GitHub's default claim template it looks +# like: +# repo:abd-ulbasit/forgepoint:ref:refs/heads/main (push to main) +# repo:abd-ulbasit/forgepoint:ref:refs/tags/v1.2.3 (tag push) +# repo:abd-ulbasit/forgepoint:pull_request (any PR run) +# Allowing only main and v* tags means a pull_request run, including from a +# fork, can never assume the role. Repositories created, renamed or transferred +# after 15 Jul 2026 get the immutable form instead +# (repo:OWNER@OWNER_ID/REPO@REPO_ID:...), so check a real token's sub before +# relying on the default list. +# ---------------------------------------------------------------------------- + +# One GitHub OIDC provider per AWS account. If the account already has one +# (another repo set it up), pass its ARN and set create_oidc_provider = false. +resource "aws_iam_openid_connect_provider" "github" { + count = var.create_oidc_provider ? 1 : 0 + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] + # No thumbprint_list: IAM validates GitHub's issuer against its own trusted + # CA store, and the provider no longer requires a pinned thumbprint. + tags = var.tags +} + +locals { + oidc_provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : var.oidc_provider_arn +} + +data "aws_iam_policy_document" "trust" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.oidc_provider_arn] + } + + # Audience must be STS (what configure-aws-credentials requests). + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + # Subject must be one of the allowed refs. StringLike so a tag pattern + # (refs/tags/v*) works; there is deliberately no bare "repo:...:*". + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = var.allowed_subjects + } + } +} + +resource "aws_iam_role" "ecr_push" { + name = "${var.name_prefix}-github-ecr-push" + assume_role_policy = data.aws_iam_policy_document.trust.json + max_session_duration = 3600 + tags = var.tags +} + +data "aws_iam_policy_document" "ecr_push" { + # GetAuthorizationToken is account-level; it cannot be scoped to a repo. + statement { + sid = "ECRLogin" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + # Push (and the reads a push needs) on the listed repositories only. + statement { + sid = "ECRPush" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:PutImage", + ] + resources = var.ecr_repository_arns + } +} + +resource "aws_iam_role_policy" "ecr_push" { + name = "ecr-push" + role = aws_iam_role.ecr_push.id + policy = data.aws_iam_policy_document.ecr_push.json +} diff --git a/deploy/terraform/modules/github-oidc/outputs.tf b/deploy/terraform/modules/github-oidc/outputs.tf new file mode 100644 index 0000000..58928f8 --- /dev/null +++ b/deploy/terraform/modules/github-oidc/outputs.tf @@ -0,0 +1,9 @@ +output "role_arn" { + description = "ARN of the push role. Store it as the repository VARIABLE (not secret) AWS_ROLE_ARN and use it as role-to-assume in aws-actions/configure-aws-credentials." + value = aws_iam_role.ecr_push.arn +} + +output "oidc_provider_arn" { + description = "ARN of the GitHub OIDC provider the role trusts." + value = local.oidc_provider_arn +} diff --git a/deploy/terraform/modules/github-oidc/variables.tf b/deploy/terraform/modules/github-oidc/variables.tf new file mode 100644 index 0000000..87b948f --- /dev/null +++ b/deploy/terraform/modules/github-oidc/variables.tf @@ -0,0 +1,37 @@ +variable "name_prefix" { + description = "Name prefix, e.g. \"forgepoint-dev\". The role is \"-github-ecr-push\"." + type = string +} + +variable "create_oidc_provider" { + description = "Create the account's GitHub OIDC provider. An account can have only one provider per URL, so set false and pass oidc_provider_arn if it already exists." + type = bool + default = true +} + +variable "oidc_provider_arn" { + description = "ARN of an existing token.actions.githubusercontent.com provider, used when create_oidc_provider = false." + type = string + default = "" +} + +variable "allowed_subjects" { + description = "OIDC `sub` patterns allowed to assume the role (StringLike). Keep these to refs that only maintainers can push: the default branch and release tags. Never a pull_request subject or a bare repo:OWNER/REPO:*." + type = list(string) + + validation { + condition = length(var.allowed_subjects) > 0 && alltrue([for s in var.allowed_subjects : !endswith(s, ":*") && !strcontains(s, "pull_request")]) + error_message = "allowed_subjects must be non-empty and must not allow pull_request runs or a bare ':*' wildcard." + } +} + +variable "ecr_repository_arns" { + description = "ECR repository ARNs the role may push to (from the ecr module's repository_arns)." + type = list(string) +} + +variable "tags" { + description = "Additional tags." + type = map(string) + default = {} +} diff --git a/deploy/terraform/modules/iam/main.tf b/deploy/terraform/modules/iam/main.tf index 34f7f28..a86ee9a 100644 --- a/deploy/terraform/modules/iam/main.tf +++ b/deploy/terraform/modules/iam/main.tf @@ -94,6 +94,26 @@ data "aws_iam_policy_document" "permissions" { } } + # --- KMS for the artifacts bucket (SSE-KMS) ------------------------------- + # Reading an SSE-KMS object needs kms:Decrypt; writing one needs + # kms:GenerateDataKey. Scoped to the artifacts key only. + dynamic "statement" { + for_each = (each.value.s3_artifact_access != "" && var.artifacts_bucket_arn != "" && var.artifacts_kms_key_arn != "") ? [each.value.s3_artifact_access] : [] + content { + sid = "ArtifactsKMS" + effect = "Allow" + actions = statement.value == "readwrite" ? [ + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:DescribeKey", + ] : [ + "kms:Decrypt", + "kms:DescribeKey", + ] + resources = [var.artifacts_kms_key_arn] + } + } + # --- Secrets Manager: GetSecretValue on the EXACT secret ARNs listed ----- # This is how a service reads its DB/Redis credentials at runtime (directly, or # via External Secrets Operator running under its own IRSA role). Scoped to the diff --git a/deploy/terraform/modules/iam/variables.tf b/deploy/terraform/modules/iam/variables.tf index 8096a34..1d142d7 100644 --- a/deploy/terraform/modules/iam/variables.tf +++ b/deploy/terraform/modules/iam/variables.tf @@ -58,6 +58,12 @@ variable "artifacts_bucket_arn" { default = "" } +variable "artifacts_kms_key_arn" { + description = "ARN of the KMS key encrypting the artifacts bucket (from the s3 module). Roles with s3_artifact_access get kms:Decrypt on it, plus kms:GenerateDataKey for readwrite (needed to PUT an SSE-KMS object). Keeps artifact access from needing a hand-maintained kms_key_arns entry." + type = string + default = "" +} + variable "tags" { description = "Additional tags (Project/Environment/ManagedBy via provider default_tags)." type = map(string) diff --git a/deploy/terraform/modules/network/main.tf b/deploy/terraform/modules/network/main.tf index c756ba2..187a85a 100644 --- a/deploy/terraform/modules/network/main.tf +++ b/deploy/terraform/modules/network/main.tf @@ -68,15 +68,22 @@ resource "aws_internet_gateway" "this" { # ---------------------------------------------------------------------------- # Subnets # ---------------------------------------------------------------------------- -# PUBLIC subnets. map_public_ip_on_launch = true so a NAT gateway / LB placed -# here gets a public IP. The kubernetes.io/role/elb tag tells the AWS Load -# Balancer Controller "put internet-facing LBs in these subnets". +# PUBLIC subnets. The kubernetes.io/role/elb tag tells the AWS Load Balancer +# Controller "put internet-facing LBs in these subnets". +# +# map_public_ip_on_launch = false (Trivy AWS-0164). Nothing here needs it: the +# NAT gateway gets its public address from the Elastic IP passed as +# allocation_id, and internet-facing ALB/NLB nodes get public addresses from AWS +# regardless of this flag. What the flag WOULD do is give a public IP to any EC2 +# instance someone later launches into these subnets (a debug box, a mis-placed +# node group), which is exactly the accident the private/public split exists to +# prevent. resource "aws_subnet" "public" { count = var.az_count vpc_id = aws_vpc.this.id cidr_block = local.public_subnet_cidrs[count.index] availability_zone = local.azs[count.index] - map_public_ip_on_launch = true + map_public_ip_on_launch = false tags = merge(var.tags, { Name = "${var.name_prefix}-public-${local.azs[count.index]}" diff --git a/deploy/terraform/modules/rds/main.tf b/deploy/terraform/modules/rds/main.tf index 68bfcde..846a6b0 100644 --- a/deploy/terraform/modules/rds/main.tf +++ b/deploy/terraform/modules/rds/main.tf @@ -2,12 +2,22 @@ # rds — PostgreSQL: private, KMS-encrypted, SG-restricted, creds in Secrets Manager # ============================================================================ # The credential flow is the centerpiece: -# random_password → aws_db_instance.master_password → aws_secretsmanager_secret -# The plaintext password exists only inside Terraform's run and the encrypted -# Secrets Manager entry. It is NEVER an input var, NEVER an output, and the random -# resource is marked sensitive so it doesn't print in plan/apply logs. The app -# reads the DSN from Secrets Manager at runtime (via External Secrets Operator in -# K8s, which maps the secret into a K8s Secret the pod envFrom's). +# manage_master_user_password = true +# → RDS generates the master password itself, stores it in a Secrets Manager +# secret it owns (rds!db-...), encrypted with our CMK, and rotates it. +# Terraform never sees the password, so it is not in state, not in plan output +# and not in any secret_string Terraform writes. (The previous design generated +# it with random_password and wrote a full DSN into a secret version, which put +# the plaintext into state, and the state bucket was only SSE-S3.) +# +# The app still gets a ready DSN: External Secrets Operator reads username + +# password from the RDS-managed secret and host/port/dbname from the +# connection-info secret below, and renders FP_DATABASE_URL with a template +# (deploy/secrets/external-secrets/). Rotation: RDS rotates the managed secret +# (every 7 days by default); ESO picks the new value up on its refresh, but env +# vars only change when a pod restarts, so pair it with a reloader or a +# scheduled rollout. Existing connections survive a rotation; new ones need +# the new password. # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- @@ -65,17 +75,6 @@ resource "aws_security_group_rule" "rds_ingress" { description = "PostgreSQL from allowed (EKS node) security group" } -# ---------------------------------------------------------------------------- -# Generated master password — never typed by a human, never committed. -# ---------------------------------------------------------------------------- -# override_special avoids characters that break DSN parsing / shell quoting in -# connection strings ('@', '/', '"', spaces). 32 chars of entropy. -resource "random_password" "master" { - length = 32 - special = true - override_special = "!#$%*-_=+[]{}<>:?" -} - # ---------------------------------------------------------------------------- # The instance. # ---------------------------------------------------------------------------- @@ -94,7 +93,11 @@ resource "aws_db_instance" "this" { db_name = var.database_name username = var.master_username - password = random_password.master.result + # RDS owns the password: generated, stored in a Secrets Manager secret it + # manages (encrypted with our CMK, not the AWS-managed key) and rotated. It + # never passes through Terraform, so it never lands in state. + manage_master_user_password = true + master_user_secret_kms_key_id = aws_kms_key.rds.arn # PRIVATE: in our subnet group, attached to the restricted SG, and explicitly # NOT publicly accessible — three independent guarantees of no internet exposure. @@ -110,7 +113,8 @@ resource "aws_db_instance" "this" { maintenance_window = "sun:04:30-sun:05:30" copy_tags_to_snapshot = true - # Safety rails (prod tightens both via tfvars). + # Safety rails. Module defaults are the SAFE values (protected, final + # snapshot taken); a throwaway env opts out explicitly in its tfvars. deletion_protection = var.deletion_protection skip_final_snapshot = var.skip_final_snapshot final_snapshot_identifier = var.skip_final_snapshot ? null : "${var.name_prefix}-postgres-final" @@ -122,30 +126,28 @@ resource "aws_db_instance" "this" { enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"] tags = merge(var.tags, { Name = "${var.name_prefix}-postgres" }) - - # The generated password churns plan output; don't let a password rotation - # outside TF (e.g. via Secrets Manager rotation) force a needless modify. - lifecycle { - ignore_changes = [password] - } } # ---------------------------------------------------------------------------- -# Secrets Manager — store the FULL connection material so apps consume it. +# Secrets Manager — connection INFO only (no password). # ---------------------------------------------------------------------------- -# We store a JSON blob (not just the password) so consumers get everything needed -# to build a DSN: host, port, user, password, the bootstrap dbname. External -# Secrets Operator in K8s reads this and projects it into a K8s Secret that the -# service pods envFrom — closing the loop from cloud secret → running container. +# host, port, dbname, username and the ARN of the RDS-managed password secret. +# None of it is a credential; it lives in Secrets Manager (rather than only in +# outputs) so External Secrets Operator can read it next to the password and +# render the DSN in-cluster. Keeping the password OUT of this secret is the +# point: the old design wrote the plaintext into secret_string, i.e. into state, +# and its aws_db_instance ignored password changes while this resource did not, +# so an out-of-band rotation set up a later apply that would overwrite the +# secret with a password the database no longer accepted. # -# recovery_window: 0 in dev lets us delete+recreate the secret immediately on -# teardown (no 7-30 day "scheduled deletion" blocking a re-apply). Prod should -# RAISE this (e.g. 7) so an accidental delete is recoverable. +# recovery_window: var.secret_recovery_window_days (safe default 30). A dev +# teardown can set 0 in tfvars so a re-apply is not blocked by a secret pending +# deletion. It used to be hard-coded to 0, which every copy of the env inherited. resource "aws_secretsmanager_secret" "db" { name = "${var.name_prefix}/rds/postgres" - description = "Forgepoint Postgres master credentials + connection info" + description = "Forgepoint Postgres connection info (no password; see master_user_secret_arn)" kms_key_id = aws_kms_key.rds.arn - recovery_window_in_days = 0 + recovery_window_in_days = var.secret_recovery_window_days tags = var.tags } @@ -156,10 +158,10 @@ resource "aws_secretsmanager_secret_version" "db" { host = aws_db_instance.this.address port = aws_db_instance.this.port username = var.master_username - password = random_password.master.result dbname = var.database_name - # A ready-to-use DSN with TLS required (RDS terminates TLS; sslmode=require - # ensures the client refuses an unencrypted connection). - dsn = "postgres://${var.master_username}:${random_password.master.result}@${aws_db_instance.this.address}:${aws_db_instance.this.port}/${var.database_name}?sslmode=require" + # Clients must use TLS (sslmode=require); ESO's DSN template adds it. + sslmode = "require" + # Where the password lives (RDS-managed, rotated). ESO reads it from there. + master_user_secret_arn = aws_db_instance.this.master_user_secret[0].secret_arn }) } diff --git a/deploy/terraform/modules/rds/outputs.tf b/deploy/terraform/modules/rds/outputs.tf index 0cc42b5..0f8f3e6 100644 --- a/deploy/terraform/modules/rds/outputs.tf +++ b/deploy/terraform/modules/rds/outputs.tf @@ -28,16 +28,21 @@ output "security_group_id" { } output "credentials_secret_arn" { - description = "ARN of the Secrets Manager secret holding the DB DSN/credentials. The iam module grants specific IRSA roles secretsmanager:GetSecretValue on THIS arn so only the right pods can read it. NOT sensitive (an ARN is not a secret), but the secret it points to is." + description = "ARN of the Secrets Manager secret holding the DB CONNECTION INFO (host, port, dbname, username, master_user_secret_arn). It contains no password. An ARN is not a secret." value = aws_secretsmanager_secret.db.arn } output "credentials_secret_name" { - description = "Name of the Secrets Manager secret (handy for External Secrets Operator ExternalSecret refs)." + description = "Name of the connection-info secret (for External Secrets Operator ExternalSecret refs)." value = aws_secretsmanager_secret.db.name } output "kms_key_arn" { - description = "ARN of the KMS key encrypting RDS storage + the credentials secret." + description = "ARN of the KMS key encrypting RDS storage, the RDS-managed master password secret and the connection-info secret." value = aws_kms_key.rds.arn } + +output "master_user_secret_arn" { + description = "ARN of the Secrets Manager secret RDS creates and rotates for the master user (JSON: username, password). Grant External Secrets Operator read on THIS arn plus kms:Decrypt on kms_key_arn; no application pod should read it directly." + value = aws_db_instance.this.master_user_secret[0].secret_arn +} diff --git a/deploy/terraform/modules/rds/variables.tf b/deploy/terraform/modules/rds/variables.tf index 6ffa129..2883a89 100644 --- a/deploy/terraform/modules/rds/variables.tf +++ b/deploy/terraform/modules/rds/variables.tf @@ -2,8 +2,9 @@ # rds — INPUTS # ============================================================================ # A PostgreSQL instance, private-subnet only, encrypted with KMS, reachable ONLY -# from the EKS nodes. Credentials are GENERATED here and stored in AWS Secrets -# Manager — they are never an input variable and never appear in plan output. +# from the EKS nodes. The master password is generated, stored and rotated by +# RDS itself (manage_master_user_password): it is never an input variable and +# never passes through Terraform, so it is not in plan output or state. # # DATABASE-PER-SERVICE note: locally we run one Postgres server with one database # per service (fp_auth, fp_registry, …). We mirror that here: one RDS INSTANCE, @@ -72,7 +73,7 @@ variable "database_name" { } variable "master_username" { - description = "Master DB username. NOT a secret on its own (the PASSWORD is what's secret); kept as a var for clarity. The password is generated by Terraform and stored in Secrets Manager." + description = "Master DB username. NOT a secret on its own (the PASSWORD is what's secret); kept as a var for clarity. The password is generated and rotated by RDS in a Secrets Manager secret it manages." type = string default = "fp_admin" } @@ -84,15 +85,26 @@ variable "backup_retention_days" { } variable "deletion_protection" { - description = "Block `terraform destroy`/console delete of the instance. FALSE in dev so the throwaway env can be torn down to save money; TRUE in prod so nobody nukes the production DB by accident." + description = "Block `terraform destroy`/console delete of the instance. SAFE DEFAULT true. A throwaway dev env sets false in its tfvars so it can be torn down." type = bool - default = false + default = true } variable "skip_final_snapshot" { - description = "Skip the final snapshot on delete. TRUE in dev (we don't care about a throwaway DB's data). FALSE in prod so a delete always leaves a recoverable snapshot." + description = "Skip the final snapshot on delete. SAFE DEFAULT false, so a delete always leaves a recoverable snapshot. A throwaway dev env sets true in its tfvars." type = bool - default = true + default = false +} + +variable "secret_recovery_window_days" { + description = "Days Secrets Manager keeps the deleted connection-info secret recoverable (7-30), or 0 to delete immediately. SAFE DEFAULT 30 (the AWS default). A throwaway dev env may set 0 so destroy + re-apply does not collide with a secret pending deletion." + type = number + default = 30 + + validation { + condition = var.secret_recovery_window_days == 0 || (var.secret_recovery_window_days >= 7 && var.secret_recovery_window_days <= 30) + error_message = "secret_recovery_window_days must be 0 or between 7 and 30." + } } variable "tags" { diff --git a/deploy/terraform/modules/s3/main.tf b/deploy/terraform/modules/s3/main.tf index a30e615..b3bfd3a 100644 --- a/deploy/terraform/modules/s3/main.tf +++ b/deploy/terraform/modules/s3/main.tf @@ -21,18 +21,41 @@ resource "aws_s3_bucket_versioning" "artifacts" { } } -# SSE-KMS by default. If a KMS key ARN was supplied we use aws:kms with that CMK -# (customer-controlled key, auditable via CloudTrail). If not, fall back to SSE-S3 -# (AES256) — still encrypted at rest, just with the AWS-managed key. bucket_key -# reduces KMS API calls (and cost) by caching a bucket-level data key. +# DEDICATED CMK for the artifacts bucket (unless the caller passes one in). +# WHY a key of its own: the dev composition used to pass the RDS key, so every +# workload allowed to read model artifacts also held kms:Decrypt on the key that +# wraps the database credential secret. One later IAM mistake (a broad +# secretsmanager:GetSecretValue) would then have been a credential read. One key +# per data class keeps the blast radius of a decrypt grant to that class. +resource "aws_kms_key" "artifacts" { + count = var.kms_key_arn == "" ? 1 : 0 + description = "${var.name_prefix} model-artifacts bucket SSE-KMS key" + enable_key_rotation = true + deletion_window_in_days = 30 + tags = merge(var.tags, { Name = "${var.name_prefix}-artifacts-kms" }) +} + +resource "aws_kms_alias" "artifacts" { + count = var.kms_key_arn == "" ? 1 : 0 + name = "alias/${var.name_prefix}-artifacts" + target_key_id = aws_kms_key.artifacts[0].key_id +} + +locals { + artifacts_kms_key_arn = var.kms_key_arn != "" ? var.kms_key_arn : aws_kms_key.artifacts[0].arn +} + +# SSE-KMS with the artifacts CMK (customer-controlled key, auditable via +# CloudTrail). bucket_key reduces KMS API calls (and cost) by caching a +# bucket-level data key. resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" { bucket = aws_s3_bucket.artifacts.id rule { apply_server_side_encryption_by_default { - sse_algorithm = var.kms_key_arn != "" ? "aws:kms" : "AES256" - kms_master_key_id = var.kms_key_arn != "" ? var.kms_key_arn : null + sse_algorithm = "aws:kms" + kms_master_key_id = local.artifacts_kms_key_arn } - bucket_key_enabled = var.kms_key_arn != "" + bucket_key_enabled = true } } diff --git a/deploy/terraform/modules/s3/outputs.tf b/deploy/terraform/modules/s3/outputs.tf index 62bb12f..c207f61 100644 --- a/deploy/terraform/modules/s3/outputs.tf +++ b/deploy/terraform/modules/s3/outputs.tf @@ -11,6 +11,11 @@ output "bucket_arn" { value = aws_s3_bucket.artifacts.arn } +output "kms_key_arn" { + description = "ARN of the KMS key encrypting the bucket (the module's own CMK unless one was passed in). Readers need kms:Decrypt on it, writers also kms:GenerateDataKey." + value = local.artifacts_kms_key_arn +} + output "bucket_regional_domain_name" { description = "Regional domain name of the bucket (e.g. .s3..amazonaws.com). Useful for S3 client endpoint config." value = aws_s3_bucket.artifacts.bucket_regional_domain_name diff --git a/deploy/terraform/modules/s3/variables.tf b/deploy/terraform/modules/s3/variables.tf index 0a80496..7f57f65 100644 --- a/deploy/terraform/modules/s3/variables.tf +++ b/deploy/terraform/modules/s3/variables.tf @@ -18,7 +18,7 @@ variable "bucket_name" { } variable "kms_key_arn" { - description = "ARN of the KMS key for SSE-KMS. Passed in (created in the env layer or eks/rds module) so all artifact encryption can share one key, or set per-env. If empty, the bucket falls back to SSE-S3 (still encrypted, AWS-managed key)." + description = "ARN of an existing KMS key for SSE-KMS. Leave EMPTY (the default) and the module creates a dedicated, rotated CMK for this bucket. Do not pass a key that protects another data class (e.g. the RDS key): every reader of artifacts would then also be able to decrypt that data." type = string default = "" } diff --git a/deploy/terraform/modules/state-backend/main.tf b/deploy/terraform/modules/state-backend/main.tf index 3538913..3daa419 100644 --- a/deploy/terraform/modules/state-backend/main.tf +++ b/deploy/terraform/modules/state-backend/main.tf @@ -1,17 +1,61 @@ # ============================================================================ -# state-backend — the S3 bucket + DynamoDB lock table that back REMOTE STATE +# state-backend — the S3 bucket (and its KMS key) that back REMOTE STATE # ============================================================================ -# Every other stack stores its state in THIS bucket and locks against THIS table. -# The bucket therefore gets the full hardening treatment: versioned (roll back a -# bad apply), encrypted (state can hold secrets), and public access fully blocked. +# Every other stack stores its state in THIS bucket. State is the most sensitive +# artifact in the repo: it holds every resource attribute, including the Redis +# AUTH token (see modules/elasticache) and anything else a provider persists. So +# the bucket gets the full treatment: versioned, encrypted with a customer-managed +# KMS key, TLS-only, public access blocked, ACLs disabled, and protected from +# `terraform destroy`. +# +# LOCKING: S3-native lock files (`use_lockfile = true` in the backend block, +# GA since Terraform 1.11). Terraform writes .tflock next to the state +# object with a conditional PUT, so there is no DynamoDB table any more; the +# DynamoDB lock is deprecated in the S3 backend. +# +# Created ONCE, by the bootstrap root (deploy/terraform/bootstrap), which starts +# with local state. See README.md "Bootstrapping order". # ---------------------------------------------------------------------------- -# The state bucket itself. Note: no `acl` argument — public-block (below) + -# bucket-owner-enforced object ownership is the modern, ACL-free way to keep a -# bucket private. +# ---------------------------------------------------------------------------- +# KMS key for the state objects (Trivy AWS-0132: S3 encryption should use a CMK). +# ---------------------------------------------------------------------------- +# WHY a CMK rather than SSE-S3: reading state then needs kms:Decrypt on THIS key +# as well as s3:GetObject, so a broad S3 read grant elsewhere in the account no +# longer exposes state, and every decrypt is a CloudTrail event. +# +# The old "no CMK, to avoid a bootstrap circular dependency" argument does not +# hold: the key and the bucket are created together by the bootstrap root (local +# state), before any stack stores state in the bucket. The real hazard is +# losing the key, which would make every state object unreadable, hence +# prevent_destroy and the maximum 30-day deletion window. +resource "aws_kms_key" "state" { + description = "Terraform remote state encryption key (${var.bucket_name})" + enable_key_rotation = true + deletion_window_in_days = 30 + tags = var.tags + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_kms_alias" "state" { + name = "alias/${var.bucket_name}" + target_key_id = aws_kms_key.state.key_id +} + +# The state bucket itself. No `acl` argument: public-block (below) + +# BucketOwnerEnforced ownership is the modern, ACL-free way to keep it private. resource "aws_s3_bucket" "state" { bucket = var.bucket_name tags = var.tags + + # A `terraform destroy` (or a refactor that drops this resource) must not be + # able to delete the bucket holding every environment's state. + lifecycle { + prevent_destroy = true + } } # VERSIONING: keep every prior version of the state object. If an apply corrupts @@ -25,24 +69,23 @@ resource "aws_s3_bucket_versioning" "state" { } } -# ENCRYPTION AT REST: SSE with the S3-managed key (SSE-S3 / AES256). We use the -# AWS-managed key here rather than a customer KMS key on purpose — the state -# bucket must be usable during bootstrap BEFORE any KMS key exists, and a KMS -# key whose own state lives in this bucket would be circular. SSE-S3 still -# encrypts everything at rest with zero key management. +# ENCRYPTION AT REST: SSE-KMS with the CMK above. bucket_key_enabled caches a +# bucket-level data key, which cuts KMS request cost. The backend block should +# also set kms_key_id to this key (see environments/dev/backend.tf); without it +# the S3 backend asks for SSE-S3 explicitly on each write. resource "aws_s3_bucket_server_side_encryption_configuration" "state" { bucket = aws_s3_bucket.state.id rule { apply_server_side_encryption_by_default { - sse_algorithm = "AES256" + sse_algorithm = "aws:kms" + kms_master_key_id = aws_kms_key.state.arn } - # bucket_key_enabled has no effect for AES256; omitted deliberately. + bucket_key_enabled = true } } -# PUBLIC ACCESS BLOCK: all four switches on. State is the most sensitive thing in -# the repo — it must NEVER be reachable from the internet, even by accident via a -# stray ACL or bucket policy. This object is the belt-and-suspenders guard. +# PUBLIC ACCESS BLOCK: all four switches on. State must NEVER be reachable from +# the internet, even by accident via a stray ACL or bucket policy. resource "aws_s3_bucket_public_access_block" "state" { bucket = aws_s3_bucket.state.id block_public_acls = true @@ -51,37 +94,76 @@ resource "aws_s3_bucket_public_access_block" "state" { restrict_public_buckets = true } -# DynamoDB LOCK TABLE. The S3 backend uses this for advisory locking: before an -# apply mutates state it writes a lock item keyed by `LockID`; concurrent applies -# see the lock and refuse, preventing two writers from racing on one state file. -# - PAY_PER_REQUEST (on-demand): locking traffic is tiny + bursty; on-demand -# costs cents and needs no capacity planning vs provisioned throughput. -# - hash key MUST be named exactly "LockID" (string) — that's the contract the -# S3 backend expects; renaming it breaks locking. -resource "aws_dynamodb_table" "lock" { - name = var.lock_table_name - billing_mode = "PAY_PER_REQUEST" - hash_key = "LockID" - - attribute { - name = "LockID" - type = "S" +# OWNERSHIP: BucketOwnerEnforced disables ACLs entirely, so access is decided by +# IAM + the bucket policy alone. +resource "aws_s3_bucket_ownership_controls" "state" { + bucket = aws_s3_bucket.state.id + rule { + object_ownership = "BucketOwnerEnforced" } +} - # Recover the table to any point in the last 35 days if it's corrupted/deleted. - # Cheap insurance for the component that guards every other apply. - point_in_time_recovery { - enabled = true +# ---------------------------------------------------------------------------- +# BUCKET POLICY +# ---------------------------------------------------------------------------- +# 1. DenyInsecureTransport: any request over plain HTTP is refused, for every +# principal (state must never cross the wire unencrypted). +# 2. DenyStateAccessToOthers (only when state_access_principal_arns is set): +# object reads/writes/deletes are refused to every principal NOT in the +# list. Bucket-level actions (e.g. PutBucketPolicy) are deliberately left +# out of the deny so an account admin can never be locked out of fixing +# this policy. +data "aws_iam_policy_document" "state" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + principals { + type = "*" + identifiers = ["*"] + } + actions = ["s3:*"] + resources = [ + aws_s3_bucket.state.arn, + "${aws_s3_bucket.state.arn}/*", + ] + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } } - # ENCRYPTION AT REST — SSE with the AWS-owned KMS key (SSE_OWNED_KEY). Like the - # S3 state bucket we deliberately avoid a customer CMK here because this table - # must be usable BEFORE any application CMK exists (bootstrap circular-dependency - # problem). AWS-owned encryption still satisfies CIS DynamoDB controls (encrypted - # at rest); it simply uses an AWS-managed root key rather than a customer one. - server_side_encryption { - enabled = true + dynamic "statement" { + for_each = length(var.state_access_principal_arns) > 0 ? [1] : [] + content { + sid = "DenyStateAccessToOthers" + effect = "Deny" + principals { + type = "*" + identifiers = ["*"] + } + actions = [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + ] + resources = ["${aws_s3_bucket.state.arn}/*"] + condition { + test = "ArnNotLike" + variable = "aws:PrincipalArn" + values = var.state_access_principal_arns + } + } } +} + +resource "aws_s3_bucket_policy" "state" { + bucket = aws_s3_bucket.state.id + policy = data.aws_iam_policy_document.state.json - tags = var.tags + # The public-access block must be in place first, or attaching a policy that + # references a "*" principal can transiently trip the block-public-policy guard. + depends_on = [aws_s3_bucket_public_access_block.state] } diff --git a/deploy/terraform/modules/state-backend/outputs.tf b/deploy/terraform/modules/state-backend/outputs.tf index a24b250..17f0443 100644 --- a/deploy/terraform/modules/state-backend/outputs.tf +++ b/deploy/terraform/modules/state-backend/outputs.tf @@ -2,25 +2,21 @@ # state-backend — OUTPUTS # ============================================================================ # These feed straight into environments//backend.tf after bootstrap: -# backend "s3" { bucket = , dynamodb_table = , ... } +# backend "s3" { bucket = , kms_key_id = , +# use_lockfile = true, encrypt = true, ... } # ============================================================================ output "state_bucket_name" { - description = "Name of the S3 bucket holding Terraform remote state. Paste into each environment's backend.tf `bucket`." + description = "Name of the S3 bucket holding Terraform remote state. Paste into each environment's backend config `bucket`." value = aws_s3_bucket.state.id } output "state_bucket_arn" { - description = "ARN of the state bucket (handy for least-privilege CI roles that need s3:GetObject/PutObject on just this bucket)." + description = "ARN of the state bucket (for least-privilege CI roles that need s3:GetObject/PutObject/DeleteObject on just this bucket; DeleteObject is needed to release the .tflock lock file)." value = aws_s3_bucket.state.arn } -output "lock_table_name" { - description = "Name of the DynamoDB lock table. Paste into each environment's backend.tf `dynamodb_table`." - value = aws_dynamodb_table.lock.name -} - -output "lock_table_arn" { - description = "ARN of the DynamoDB lock table (for scoping CI role permissions to just this table)." - value = aws_dynamodb_table.lock.arn +output "kms_key_arn" { + description = "ARN of the KMS key encrypting state. Paste into each environment's backend config `kms_key_id`; roles that run Terraform need kms:Decrypt and kms:GenerateDataKey on it." + value = aws_kms_key.state.arn } diff --git a/deploy/terraform/modules/state-backend/variables.tf b/deploy/terraform/modules/state-backend/variables.tf index 59ddfd4..c8c91a4 100644 --- a/deploy/terraform/modules/state-backend/variables.tf +++ b/deploy/terraform/modules/state-backend/variables.tf @@ -1,26 +1,26 @@ # ============================================================================ # state-backend — INPUTS # ============================================================================ -# Creates the S3 bucket + DynamoDB lock table that hold Terraform REMOTE STATE -# for every other stack. This module is the one exception to "state lives in S3": -# it is bootstrapped with LOCAL state (you can't store state in a bucket that -# doesn't exist yet), then the bucket it creates becomes the home for all other -# stacks. See README.md → "Bootstrapping order". +# Creates the S3 bucket + KMS key that hold Terraform REMOTE STATE for every +# other stack. This module is the one exception to "state lives in S3": the +# bootstrap root that calls it (deploy/terraform/bootstrap) starts with LOCAL +# state (you can't store state in a bucket that doesn't exist yet). See +# README.md → "Bootstrapping order". # ============================================================================ variable "bucket_name" { - description = "Globally-unique S3 bucket name for Terraform state. Convention: forgepoint-tfstate-. Passed in (not derived) so the name is explicit and stable." + description = "Globally-unique S3 bucket name for Terraform state. Convention: forgepoint-tfstate-. Passed in (not derived) so the name is explicit and stable. Also used for the KMS alias (alias/)." type = string } -variable "lock_table_name" { - description = "DynamoDB table name used for state locking. One table can lock many state keys (each stack uses a different LockID), so a single table serves all environments." - type = string - default = "forgepoint-tflock" +variable "state_access_principal_arns" { + description = "IAM principal ARNs (wildcards allowed, e.g. arn:aws:iam::123456789012:role/terraform-*) that may read, write or delete state objects. When non-empty, the bucket policy DENIES object access to every other principal, so state is readable only by the roles that run Terraform. Empty (the default) adds no such restriction; access is then whatever IAM allows, plus kms:Decrypt on the state key. Bucket-level actions are never denied, so an admin cannot be locked out of the policy itself." + type = list(string) + default = [] } variable "tags" { - description = "Tags merged onto every resource. The environments layer supplies Project/Environment/ManagedBy via provider default_tags; anything passed here is additive." + description = "Tags merged onto every resource. The calling root supplies Project/Environment/ManagedBy via provider default_tags; anything passed here is additive." type = map(string) default = {} } diff --git a/deploy/terraform/versions.tf b/deploy/terraform/versions.tf index e3d2481..d483baa 100644 --- a/deploy/terraform/versions.tf +++ b/deploy/terraform/versions.tf @@ -17,9 +17,10 @@ # ============================================================================ terraform { - # 1.14.x is what this repo is validated against. Allow forward patch/minor, - # forbid the next major where breaking language changes could land. - required_version = ">= 1.9.0, < 2.0.0" + # 1.14.x is what this repo is validated against. >= 1.11 because the S3 + # backend's use_lockfile is GA from 1.11. Allow forward patch/minor, forbid + # the next major where breaking language changes could land. + required_version = ">= 1.11.0, < 2.0.0" required_providers { # The AWS provider is the workhorse — VPC, EKS, RDS, ElastiCache, S3, ECR, IAM. diff --git a/docker-compose.yaml b/docker-compose.yaml index d889e94..1c02750 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,11 +13,25 @@ # VERIFICATION: # curl http://localhost:8222/healthz # NATS # psql -h localhost -U fp -c "SELECT 1" # Postgres (password: fp) -# redis-cli -h localhost ping # Redis +# redis-cli -h localhost -a fp ping # Redis (password: fp) # curl http://localhost:9000/minio/health/live # MinIO # curl http://localhost:9090/-/healthy # Prometheus # curl http://localhost:3000/api/health # Grafana (admin/admin) # +# NETWORK EXPOSURE: every published port binds to ${FP_BIND_ADDR:-127.0.0.1}. +# Docker's default for "5432:5432" is 0.0.0.0, i.e. every interface of the +# machine running the ENGINE. With a remote engine (docker context over ssh) +# that is the remote box's LAN and VPN interfaces, not your laptop. To reach +# the stack from another machine, prefer an ssh tunnel +# (ssh -L 5432:127.0.0.1:5432 ). Only set FP_BIND_ADDR to a +# non-loopback address after replacing every *_PASSWORD default in .env. +# +# AUTH: Redis (requirepass) and NATS (user/password) require credentials, +# defaulting to the same dev value as Postgres ("fp"). The defaults are +# published, so they are not the control; the loopback bind is. They exist so +# that setting real values in .env takes effect end to end. The Go services' +# local defaults (FP_REDIS_URL / FP_NATS_URL) carry the same dev credentials. +# # ARCHITECTURE: # ┌─────────────────────────────────────────────────────┐ # │ Forgepoint Services │ @@ -42,11 +56,12 @@ services: # NATS — Async event bus (JetStream for persistence) # ========================================================================== nats: - image: nats:2.11 - command: ["--jetstream", "--http_port", "8222", "-DV"] + image: nats:2.11.17@sha256:c991221cdddd53a24893cd18a90faf7a42396108496aed076bb982a349e76484 + # --user/--pass: clients must authenticate (nats://fp:@host). + command: ["--jetstream", "--http_port", "8222", "--user", "fp", "--pass", "${NATS_PASSWORD:-fp}", "-DV"] ports: - - "4222:4222" # Client connections (accessible by services) - - "127.0.0.1:8222:8222" # HTTP monitoring — localhost only (not LAN-exposed) + - "${FP_BIND_ADDR:-127.0.0.1}:4222:4222" # Client connections + - "127.0.0.1:8222:8222" # HTTP monitoring — always localhost only volumes: - nats-data:/data restart: unless-stopped @@ -61,13 +76,13 @@ services: # REGISTRY_DATABASE_URL=postgres://fp:fp@localhost:5432/fp_registry # ========================================================================== postgres: - image: postgres:17 + image: postgres:17.11@sha256:b8faeff7712663c2773a190181e1246e37e364b887611c04658c28b44e2408fe environment: POSTGRES_USER: fp POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fp} POSTGRES_DB: fp ports: - - "5432:5432" + - "${FP_BIND_ADDR:-127.0.0.1}:5432:5432" volumes: - postgres-data:/var/lib/postgresql/data - ./scripts/init-postgres.sh:/docker-entrypoint-initdb.d/init-postgres.sh:ro @@ -77,9 +92,11 @@ services: # REDIS — Caching (registry read model, rate limiting, sessions) # ========================================================================== redis: - image: redis:7 + image: redis:7.4.11@sha256:44960b3100c48159a66d28c18b0588c193492e25f7551d60e51dd464fc3eca5b + # requirepass: every command needs AUTH (redis://:@host:6379). + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-fp}"] ports: - - "6379:6379" + - "${FP_BIND_ADDR:-127.0.0.1}:6379:6379" volumes: - redis-data:/data restart: unless-stopped @@ -101,15 +118,18 @@ services: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} ports: - - "9000:9000" # S3 API (used by services — keep accessible) - - "127.0.0.1:9001:9001" # Web console — localhost only + - "${FP_BIND_ADDR:-127.0.0.1}:9000:9000" # S3 API (used by services) + - "127.0.0.1:9001:9001" # Web console — always localhost only volumes: - minio-data:/data restart: unless-stopped # Create default bucket on startup minio-init: - image: minio/mc:latest + # NOTE: minio/minio and minio/mc no longer exist on Docker Hub (the + # repositories return 404), so neither image can be pinned by digest here. + # Pulls only work from a local cache. Replacing MinIO is tracked separately. + image: minio/mc:RELEASE.2025-04-16T18-13-26Z depends_on: minio: condition: service_started @@ -128,9 +148,9 @@ services: # PROMETHEUS — Metrics collection (pull-based scraping) # ========================================================================== prometheus: - image: prom/prometheus:latest + image: prom/prometheus:v3.1.0@sha256:6559acbd5d770b15bb3c954629ce190ac3cbbdb2b7f1c30f0385c4e05104e218 ports: - - "9090:9090" + - "127.0.0.1:9090:9090" volumes: - ./deploy/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus @@ -148,7 +168,7 @@ services: # Default login: admin / admin # ========================================================================== grafana: - image: grafana/grafana:latest + image: grafana/grafana:11.5.1@sha256:5781759b3d27734d4d548fcbaf60b1180dbf4290e708f01f292faa6ae764c5e6 ports: - "127.0.0.1:3000:3000" # UI — localhost only; not exposed to LAN environment: @@ -176,11 +196,11 @@ services: # Grafana queries Tempo to display trace waterfalls. # ========================================================================== tempo: - image: grafana/tempo:latest + image: grafana/tempo:2.7.1@sha256:4443be217c396b065ee34845534199c36fdba4dc619cb96550e228d73fba6e69 command: ["-config.file=/etc/tempo.yaml"] ports: - - "4317:4317" # OTLP gRPC (OpenTelemetry traces) - - "3200:3200" # Tempo HTTP API (Grafana queries this) + - "${FP_BIND_ADDR:-127.0.0.1}:4317:4317" # OTLP gRPC (OpenTelemetry traces) + - "127.0.0.1:3200:3200" # Tempo HTTP API (Grafana queries this) volumes: - ./deploy/tempo/tempo.yaml:/etc/tempo.yaml:ro - tempo-data:/tmp/tempo @@ -194,9 +214,9 @@ services: # Locally, services can push directly to Loki or we can add promtail later. # ========================================================================== loki: - image: grafana/loki:latest + image: grafana/loki:3.4.2@sha256:58a6c186ce78ba04d58bfe2a927eff296ba733a430df09645d56cdc158f3ba08 ports: - - "3100:3100" + - "127.0.0.1:3100:3100" volumes: - loki-data:/loki restart: unless-stopped diff --git a/docs/adr/0006-go-workspace-monorepo.md b/docs/adr/0006-go-workspace-monorepo.md index e99d86a..0cfb6f4 100644 --- a/docs/adr/0006-go-workspace-monorepo.md +++ b/docs/adr/0006-go-workspace-monorepo.md @@ -71,7 +71,7 @@ its own `go.mod`; `pkg/` and `gen/go/` are sibling modules the workspace resolve Adopt **Option D**: a **Go workspace monorepo** — `go.work` + a module per service + `gen/go` + `pkg`. The `go.work` `use(...)` block lists `./gen/go`, `./pkg`, `./cli` and every -`./services/*` module (15 entries today); the workspace declares `go 1.26.0`. +`./services/*` module (15 entries today); the workspace declares `go 1.26.10`. Three gotchas are part of the decision (each cost real debugging; all are now encoded in `docs/design/service-architecture.md` and `services/auth/Dockerfile`): @@ -108,8 +108,8 @@ RUN go work init ./pkg ./gen/go ./services/auth && go mod download -x all This is **self-contained** (adding another service never breaks an existing image), **correct** (`./pkg`/`./gen/go` resolve to local source exactly as on a laptop), and keeps **module isolation honest** — a service image can only build against `pkg`, `gen/go`, and its own -source, never another service's. The builder image is pinned to `golang:1.26-alpine` to match -the workspace's `go 1.26.0` directive (an older toolchain refuses to build a newer workspace). +source, never another service's. The builder image is pinned to `golang:1.26.10-alpine` to match +the `go 1.26.10` directive in the workspace and every module (an older toolchain refuses to build a newer workspace). Deciding factor: Option D is the only one that delivers share-without-publish **and** per-service dependency/build isolation; the gotchas are one-time costs already paid and diff --git a/docs/adr/0010-asymmetric-jwt-signing.md b/docs/adr/0010-asymmetric-jwt-signing.md new file mode 100644 index 0000000..11f2d07 --- /dev/null +++ b/docs/adr/0010-asymmetric-jwt-signing.md @@ -0,0 +1,94 @@ +# ADR 0010: Asymmetric JWT signing with a published key set (replace the shared HS256 key) + +**Status:** Proposed +**Date:** 2026-10-24 +**Deciders:** Abdul Basit Sajid +**Context phase:** Security audit follow-up (October 2026). Design note only; no code changes yet. + +## Context + +`services/auth` mints access tokens with **HS256** (`services/auth/internal/domain/jwt.go`), +and every other service verifies them locally with `pkg/auth/validator.go`, using the +**same** symmetric key. That key, `FP_JWT_SECRET`, is rendered into the Secret of 11 Helm +charts (all 11 `templates/secret.yaml` files reference it) and stored once in Secrets Manager +(`/auth/jwt`, created by hand with the aws CLI). + +The verifier side is careful: it pins the algorithm (identity check on +`jwt.SigningMethodHS256` plus `WithValidMethods`), rejects `alg=none` and enforces a +key of at least 32 bytes. The weakness is structural: + +1. **Every verifier can mint.** With HMAC the verification key *is* the signing key. + Compromising any one of the 11 services (billing, notification, the BFF's + neighbours...) yields a key that forges a token for **any** identity and role, + including admin, accepted by every other service. +2. **No key rotation without an outage.** The validator holds exactly one key and + tokens carry no `kid`. Rotating means updating 11 Secrets and restarting 11 + services in lockstep; any service still on the old key rejects new tokens (or + the reverse) until it restarts. +3. **The blast radius of a leaked key is the whole platform,** and the old dev + placeholder value is already public in git history (see SECURITY.md), so any + environment that ever used it must rotate. Today that rotation is the painful + lockstep in point 2. + +## Options Considered + +### Option A — Keep HS256, add a `kid` and accept a key *set* in the validator +- **Pro:** smallest change; enables zero-downtime rotation (sign with the new key, + verify with old+new, then drop the old one). +- **Con:** does not fix point 1. Every verifier still holds a minting key. + +### Option B — Asymmetric signing (EdDSA/Ed25519 or ES256), JWKS published by auth (recommended) +- **Pro:** only `auth` holds a private key; the other services get **public** keys + that cannot mint anything, so a compromised service can no longer forge + identities. Key IDs (`kid`) and a key set give zero-downtime rotation. Public keys + are not secrets, so they no longer need a Secret per chart. +- **Con:** a key-distribution path is needed (a JWKS endpoint on auth, or a ConfigMap / + Secrets Manager entry with the public set); validators must cache and refresh it; + the token format changes (`alg`, `kid`), so the migration has a dual-accept window. + +### Option C — Opaque tokens with introspection at auth (or the mesh) +- **Pro:** no signing key outside auth at all; instant revocation. +- **Con:** a network call per request (or per cache miss) to auth, and a hard runtime + dependency on auth for every RPC. It undoes the design choice of local verification. + +## Decision + +Proposed: **Option B, with Ed25519 (`EdDSA`)**. Small keys and fast verification, +and the algorithm admits no parameter choices that could go wrong. `ES256` is the +fallback if a client library lacks EdDSA. + +- auth holds the private key(s) (Secrets Manager, read only by the External Secrets + Operator into the `fp-auth` Secret). It signs with the active key, puts its `kid` in + the header, and serves the **public** key set at an internal JWKS endpoint (and/or + writes it to a ConfigMap that charts mount). +- `pkg/auth` validator: accept exactly `EdDSA`, look up the key by `kid` in a cached + set (refresh on unknown `kid`, rate-limited), and keep the existing + algorithm-pinning and claim checks. Add `iss` and `aud` checks while there. +- Charts: drop `FP_JWT_SECRET` from every chart except `fp-auth`. + +## Migration + +1. **Validator first (no behaviour change):** `pkg/auth` accepts a *set* of keys: + the current HS256 secret **and** an Ed25519 public key set. HS256 tokens still + verify. Ship to all services. +2. **auth signs EdDSA with `kid`:** generate the first Ed25519 key, publish its public + half, and switch issuance. Old HS256 tokens keep verifying until they expire + (access-token TTL). +3. **Remove HS256:** after one TTL plus a margin, drop HS256 acceptance from the + validator and remove `FP_JWT_SECRET` from the 11 non-auth charts, the ExternalSecret + and Secrets Manager. Keep the dev-placeholder denylist in the fp-auth chart until + then. +4. **Rotation runbook:** add key N+1 to the published set, wait for every validator's + cache refresh interval, switch signing to N+1, and remove N after one token TTL. + No restarts are needed. + +## Consequences + +- **Positive:** a compromised non-auth service can no longer mint tokens; rotation is + routine instead of a coordinated outage; fewer Secrets to manage. +- **Negative:** auth gains a JWKS endpoint and key-management code; validators gain a + cache with refresh logic; a short dual-algorithm window. +- **Follow-ups:** decide JWKS-over-HTTP vs ConfigMap distribution (ConfigMap is simpler + inside one cluster; HTTP is needed once anything outside the cluster verifies); + until this lands, any environment that ever used the public dev placeholder as its + HS256 key must rotate it now. diff --git a/docs/adr/0011-per-service-database-users.md b/docs/adr/0011-per-service-database-users.md new file mode 100644 index 0000000..db5ef0a --- /dev/null +++ b/docs/adr/0011-per-service-database-users.md @@ -0,0 +1,85 @@ +# ADR 0011: Per-service database users instead of the shared master DSN + +**Status:** Proposed +**Date:** 2026-10-24 +**Deciders:** Abdul Basit Sajid +**Context phase:** Security audit follow-up (October 2026). Design note only; no code changes yet. + +## Context + +The architecture is database-per-service ("each service owns its Postgres database and +migrations; no cross-service DB access", per `deploy/k8s/infra/postgres/postgres.yaml` +and the `fp_` databases in `scripts/init-postgres.sh`), but the *credentials* +are not per-service: + +- **AWS:** both ExternalSecrets (`fp-auth`, `fp-infra-creds`) build `FP_DATABASE_URL` from + the **RDS master user**, now RDS-managed (`manage_master_user_password`) but still the + master. Every consumer of `fp-infra-creds` and the auth service connect as a role that + owns every database, can create roles, and can read and alter any service's data. +- **Local / k3s:** every service connects as `fp`, the Postgres superuser created by the + image entrypoint. + +So the isolation boundary the architecture promises exists only by convention. One +compromised service, or one SQL injection, gets read/write on billing's ledger, auth's +users and the audit log, and could create a login role to persist. The repo already +shows the pattern it should follow in one place: `fp_audit_app`, a NOLOGIN least-privilege +role for the audit sink. + +## Options Considered + +### Option A — Status quo: one master DSN, rely on network policy and mesh authz +- **Pro:** nothing to build. +- **Con:** database-level isolation is zero; the master credential sits in many pods' env. + +### Option B — One LOGIN role per service, owner of its own database only (recommended) +- **Pro:** a compromised service reaches only its own database; the credential has no + `CREATEROLE`/`CREATEDB`/superuser; audit logs show *which* service did what. +- **Con:** role provisioning, password storage and rotation for ~8 roles; migrations + must run as the service's owner role. + +### Option C — Option B with IAM database authentication on RDS +- **Pro:** no stored passwords at all; pods get 15-minute tokens via IRSA + (`rds-db:connect` scoped to one DB user); rotation is free. +- **Con:** RDS-only (the local stack still needs passwords); a client change to fetch + tokens and reconnect before expiry; IAM auth has connection-rate limits to plan around. + +## Decision + +Proposed: **Option B now, Option C for AWS later.** + +- Each service gets a role `fp__app` (LOGIN, NOINHERIT, no CREATEROLE/CREATEDB) + that **owns only** `fp_`. `REVOKE CONNECT ON DATABASE ... FROM PUBLIC` on + every service database, so a role cannot even connect to another service's DB. +- The master/superuser credential is used **only** by a one-shot provisioning Job (or + Terraform's `postgresql` provider in a separate, operator-run stack) and is never + mounted into an application pod. +- Passwords: one Secrets Manager secret per service role (AWS), synced by its own + ExternalSecret into that service's Secret only (`fp-`), replacing the shared + `fp-infra-creds` DSN. + +## Migration + +1. Provisioning: extend `scripts/init-postgres.sh` (local) and add an idempotent + provisioning Job for RDS that creates the roles, transfers ownership of each + `fp_` database and its objects (`REASSIGN OWNED`), and revokes PUBLIC + connect. +2. Secrets: create per-service secrets; add one ExternalSecret per service; point each + chart's `secrets.existingSecret` at it (the charts already default to a Secret named + after the chart, see `secrets.create: false`). +3. Cut over one service at a time; migrations (the `migrate` initContainer) already use + the same DSN as the app, so they run as the owner role automatically. +4. Remove `FP_DATABASE_URL` from `fp-infra-creds`, and drop the master user secret ARN + from every role except the provisioning path. +5. Later (Option C): enable IAM auth on RDS, grant `rds_iam` to the service roles, give + each service's IRSA role `rds-db:connect` on its own user only, and switch clients to + token auth. + +## Consequences + +- **Positive:** database isolation matches the architecture; credential blast radius is + one service; the master credential leaves the application path entirely. +- **Negative:** ~8 more secrets to provision and rotate (until IAM auth); a provisioning + step that must run before a new service's first deploy. +- **Follow-ups:** decide where role provisioning lives (Job vs operator-run Terraform + stack); the RDS-managed master password rotation (every 7 days) then affects only + that provisioning path, not every pod. diff --git a/docs/adr/README.md b/docs/adr/README.md index c77469a..423202c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,3 +18,5 @@ zero-padded (`0001`, `0002`, …). Use `docs/adr/template.md` as the starting po | [0007](0007-one-pattern-per-service.md) | One distributed-systems pattern per service | Accepted | 2026-06-17 | | [0008](0008-istio-service-mesh.md) | Istio service mesh — STRICT mTLS, deny-by-default authz, mesh resilience | Accepted | 2026-06-18 | | [0009](0009-tamper-evident-audit-log.md) | Tamper-evident, hash-chained audit log (interceptor capture + append-only sink) | Accepted | 2026-06-18 | +| [0010](0010-asymmetric-jwt-signing.md) | Asymmetric JWT signing with a published key set (replace the shared HS256 key) | Proposed | 2026-10-24 | +| [0011](0011-per-service-database-users.md) | Per-service database users instead of the shared master DSN | Proposed | 2026-10-24 | diff --git a/gen/go/go.mod b/gen/go/go.mod index 96d8e2c..2c03c43 100644 --- a/gen/go/go.mod +++ b/gen/go/go.mod @@ -10,26 +10,18 @@ // bump protobuf/grpc versions for the whole platform. module github.com/abd-ulbasit/forgepoint/gen/go -go 1.25.0 +go 1.26.10 require ( - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/gen/go/go.sum b/gen/go/go.sum index 754ac92..39b943e 100644 --- a/gen/go/go.sum +++ b/gen/go/go.sum @@ -1,8 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -13,59 +12,27 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/go.work b/go.work index 4267844..66fa859 100644 --- a/go.work +++ b/go.work @@ -33,7 +33,7 @@ // NOTE: Services are added to `use()` as they're built. Start with just // pkg (shared libs) and auth (first service). -go 1.26.0 +go 1.26.10 use ( ./cli diff --git a/pkg/config/config.go b/pkg/config/config.go index 2bf84af..c7d3a03 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -90,14 +90,16 @@ import ( // because gRPC uses HTTP/2 and health checks use HTTP/1.1) // - LogLevel: Controls slog verbosity (debug/info/warn/error) // - OTelEndpoint: OpenTelemetry collector for traces/metrics -// - NATSUrl: Async event bus connection +// - NATSUrl: Async event bus connection. The default carries the local +// docker-compose dev credentials (NATS_PASSWORD, default "fp"); every real +// deployment sets FP_NATS_URL explicitly. // - DatabaseURL: Per-service PostgreSQL database type BaseConfig struct { Port int `env:"PORT" default:"8080"` GRPCPort int `env:"GRPC_PORT" default:"9090"` LogLevel string `env:"LOG_LEVEL" default:"info"` OTelEndpoint string `env:"OTEL_ENDPOINT" default:"localhost:4317"` - NATSUrl string `env:"NATS_URL" default:"nats://localhost:4222"` + NATSUrl string `env:"NATS_URL" default:"nats://fp:fp@localhost:4222"` DatabaseURL string `env:"DATABASE_URL"` } diff --git a/pkg/config/redact.go b/pkg/config/redact.go new file mode 100644 index 0000000..1a608f0 --- /dev/null +++ b/pkg/config/redact.go @@ -0,0 +1,30 @@ +package config + +import ( + "net/url" + "strings" +) + +// RedactURL returns a connection URL that is safe to log: any password in the +// userinfo is replaced by "xxxxx" (net/url's own convention), so +// "redis://:s3cret@host:6379" logs as "redis://:xxxxx@host:6379". +// +// WHY this exists: the services log their NATS and Redis URLs at startup. In +// AWS those URLs come from Secrets Manager via External Secrets Operator and +// embed the ElastiCache auth token (rediss://:@host), so logging them +// verbatim copies a credential into every log sink. +// +// Values without a scheme (a bare "host:port", as the registry and billing +// Redis config allow) carry no userinfo and are returned unchanged. A value +// that does not parse as a URL is NOT echoed back, because it may still +// contain a secret; a fixed placeholder is returned instead. +func RedactURL(raw string) string { + if !strings.Contains(raw, "://") { + return raw + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + return u.Redacted() +} diff --git a/pkg/config/redact_test.go b/pkg/config/redact_test.go new file mode 100644 index 0000000..f0a5d60 --- /dev/null +++ b/pkg/config/redact_test.go @@ -0,0 +1,26 @@ +package config + +import "testing" + +func TestRedactURL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"redis password only", "redis://:s3cret@localhost:6379", "redis://:xxxxx@localhost:6379"}, + {"rediss with token", "rediss://:tok3n@master.example.cache.amazonaws.com:6379", "rediss://:xxxxx@master.example.cache.amazonaws.com:6379"}, + {"nats user and password", "nats://fp:s3cret@localhost:4222", "nats://fp:xxxxx@localhost:4222"}, + {"no credentials", "nats://nats.fp-infra.svc.cluster.local:4222", "nats://nats.fp-infra.svc.cluster.local:4222"}, + {"bare host:port", "localhost:6379", "localhost:6379"}, + {"empty", "", ""}, + {"unparseable is not echoed", "redis://:pa ss@%zz", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RedactURL(tt.in); got != tt.want { + t.Errorf("RedactURL(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/pkg/go.mod b/pkg/go.mod index ba01a95..7a17643 100644 --- a/pkg/go.mod +++ b/pkg/go.mod @@ -1,6 +1,6 @@ module github.com/abd-ulbasit/forgepoint/pkg -go 1.25.0 +go 1.26.10 require ( github.com/golang-jwt/jwt/v5 v5.3.1 @@ -12,15 +12,15 @@ require ( github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 github.com/testcontainers/testcontainers-go/modules/redis v0.43.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 - go.opentelemetry.io/otel/sdk v1.44.0 - go.opentelemetry.io/otel/sdk/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 - google.golang.org/grpc v1.82.1 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 + go.opentelemetry.io/otel/sdk v1.45.0 + go.opentelemetry.io/otel/sdk/metric v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) @@ -42,22 +42,22 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/jackc/pgx/v5 v5.10.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/go-archive v0.3.0 // indirect github.com/moby/moby/api v1.54.2 // indirect github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/nats-io/nkeys v0.4.11 // indirect @@ -74,15 +74,15 @@ require ( github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/go.sum b/pkg/go.sum index 6aa1c73..0de6e5a 100644 --- a/pkg/go.sum +++ b/pkg/go.sum @@ -6,8 +6,6 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/abd-ulbasit/forgepoint/gen/go v0.0.0-20260727085324-627e50284248 h1:4wApdYdDzHhen3IrQ44bwbV9hsh2AEdaE2KKJNYkIQI= -github.com/abd-ulbasit/forgepoint/gen/go v0.0.0-20260727085324-627e50284248/go.mod h1:GHrtFUKXypVBbvIrPMs18u2+G3F8goedJ6C8utK232s= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -41,8 +39,8 @@ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLA github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= @@ -68,8 +66,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -92,18 +90,18 @@ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dz github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= -github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c= +github.com/moby/go-archive v0.3.0/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -160,60 +158,57 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 h1:klTViGcsvLCd1xN3rZzfZ12NslC/OimbmR+k+A006RI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0/go.mod h1:jRsK04CWmXuY8A0O+wMpSf+t90RHZ53o5Qmxn2PQPfk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/natsutil/connection.go b/pkg/natsutil/connection.go index 4c88d57..e52fa54 100644 --- a/pkg/natsutil/connection.go +++ b/pkg/natsutil/connection.go @@ -78,7 +78,7 @@ func Connect(url string, opts ...nats.Option) (*nats.Conn, jetstream.JetStream, } }), nats.ReconnectHandler(func(nc *nats.Conn) { - slog.Info("NATS reconnected", slog.String("url", nc.ConnectedUrl())) + slog.Info("NATS reconnected", slog.String("url", nc.ConnectedUrlRedacted())) }), } diff --git a/pkg/observability/setup.go b/pkg/observability/setup.go index c41e158..018c031 100644 --- a/pkg/observability/setup.go +++ b/pkg/observability/setup.go @@ -85,15 +85,15 @@ import ( // resource.Default() for this otel release. resource.Merge() rejects two // resources whose Schema URLs differ, so a mismatch makes Setup() return // "conflicting Schema URL" — a RUNTIME failure in every service's - // composition root, invisible to the compiler. otel 1.44's - // resource.Default() carries schema 1.41.0. + // composition root, invisible to the compiler. otel 1.45's + // resource.Default() carries schema 1.43.0. // // Bump this together with the otel modules. TestSetup_* in this package is - // what catches it if you don't: it has now caught it on two consecutive - // otel upgrades (1.40 -> 1.42 -> 1.44), which is the argument for keeping - // the version pinned here rather than reaching for resource.NewSchemaless - // to make the mismatch impossible-and-silent. - semconv "go.opentelemetry.io/otel/semconv/v1.41.0" + // what catches it if you don't: it has now caught it on three consecutive + // otel upgrades (1.40 -> 1.42 -> 1.44 -> 1.45), which is the argument for + // keeping the version pinned here rather than reaching for + // resource.NewSchemaless to make the mismatch impossible-and-silent. + semconv "go.opentelemetry.io/otel/semconv/v1.43.0" ) // setupCalled (guarded by setupMu) ensures Setup() runs at most once per process. diff --git a/services/ai-gateway/Dockerfile b/services/ai-gateway/Dockerfile index 8120ca1..ae2c51c 100644 --- a/services/ai-gateway/Dockerfile +++ b/services/ai-gateway/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/ai-gateway/ ./services/ai-gateway/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/ai-gateway/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/ai-gateway/cmd/server diff --git a/services/ai-gateway/cmd/server/main.go b/services/ai-gateway/cmd/server/main.go index c24c1cf..d7bfcb2 100644 --- a/services/ai-gateway/cmd/server/main.go +++ b/services/ai-gateway/cmd/server/main.go @@ -74,6 +74,11 @@ import ( "github.com/abd-ulbasit/forgepoint/services/ai-gateway/internal/usage" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // serviceName is the canonical identity for telemetry, the NATS event source, and // consumer-group naming. It MUST match events.Source. const serviceName = "ai-gateway" @@ -92,7 +97,7 @@ type AIConfig struct { JWTSecret string `env:"JWT_SECRET" required:"true"` // RedisURL backs the per-team token budgets. Defaulted for local docker-compose. - RedisURL string `env:"REDIS_URL" default:"redis://localhost:6379"` + RedisURL string `env:"REDIS_URL" default:"redis://:fp@localhost:6379"` // OllamaURL is the in-cluster Ollama serving endpoint. Defaulted to the fp-ml // Service; local dev can point it at a laptop Ollama or leave it (the stub serves @@ -204,7 +209,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: serviceName, - ServiceVersion: "dev", + ServiceVersion: version, Environment: cfg.Environment, OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: true, @@ -238,7 +243,7 @@ func main() { os.Exit(1) } pingCancel() - logger.Info("redis connected", slog.String("url", cfg.RedisURL)) + logger.Info("redis connected", slog.String("url", config.RedactURL(cfg.RedisURL))) // 4b. NATS JetStream — events + warm signal. natsConn, js, err := natsutil.Connect(cfg.NATSUrl) @@ -251,7 +256,7 @@ func main() { logger.Error("nats drain error", slog.String("error", drainErr.Error())) } }() - logger.Info("NATS connected", slog.String("url", cfg.NATSUrl)) + logger.Info("NATS connected", slog.String("url", config.RedactURL(cfg.NATSUrl))) // 5. ENSURE STREAMS (idempotent; fail fast — a gateway that can't guarantee its // streams would silently drop cost events and never wake Ollama). diff --git a/services/ai-gateway/go.mod b/services/ai-gateway/go.mod index c599910..1dac093 100644 --- a/services/ai-gateway/go.mod +++ b/services/ai-gateway/go.mod @@ -1,13 +1,13 @@ module github.com/abd-ulbasit/forgepoint/services/ai-gateway -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats.go v1.48.0 github.com/redis/go-redis/v9 v9.20.1 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) @@ -19,24 +19,18 @@ require ( ) require ( - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/ai-gateway/go.sum b/services/ai-gateway/go.sum index 58875f2..3f2d873 100644 --- a/services/ai-gateway/go.sum +++ b/services/ai-gateway/go.sum @@ -5,9 +5,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -24,8 +23,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= @@ -46,71 +45,32 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index cd96574..a2bec65 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/auth/ ./services/auth/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/auth/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/auth/cmd/server diff --git a/services/auth/cmd/server/main.go b/services/auth/cmd/server/main.go index 43bd757..a2c299c 100644 --- a/services/auth/cmd/server/main.go +++ b/services/auth/cmd/server/main.go @@ -101,6 +101,11 @@ import ( "github.com/abd-ulbasit/forgepoint/services/auth/internal/repository/postgres" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // AuthConfig extends BaseConfig with auth-specific configuration. // // WHY embed BaseConfig: @@ -199,7 +204,7 @@ func main() { // config and TLS is on. otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "auth", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: true, diff --git a/services/auth/go.mod b/services/auth/go.mod index 21bb3b3..ad41fda 100644 --- a/services/auth/go.mod +++ b/services/auth/go.mod @@ -1,41 +1,34 @@ module github.com/abd-ulbasit/forgepoint/services/auth -go 1.25.0 +go 1.26.10 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 - golang.org/x/crypto v0.52.0 - google.golang.org/grpc v1.82.1 + golang.org/x/crypto v0.57.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( github.com/jackc/pgx/v5 v5.10.0 - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/auth/go.sum b/services/auth/go.sum index 7ae00d6..ec496ea 100644 --- a/services/auth/go.sum +++ b/services/auth/go.sum @@ -3,9 +3,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -22,8 +21,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= @@ -39,65 +38,30 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/bff/Dockerfile b/services/bff/Dockerfile index 340c509..d1f530a 100644 --- a/services/bff/Dockerfile +++ b/services/bff/Dockerfile @@ -34,10 +34,11 @@ # Stage 1: builder — full Go toolchain, compiles the static binary. # ---------------------------------------------------------------------------- # Pinned by multi-arch index DIGEST (not a floating tag) for hermetic, reproducible -# builds across amd64/arm64. The builder is 1.26 to match go.work's `go 1.26.0` -# directive (a 1.25 toolchain refuses to build a workspace declaring a newer Go). -# Refresh with: docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# builds across amd64/arm64. The builder is 1.26.10 to match the `go 1.26.10` +# directive in go.work and every go.mod (with GOTOOLCHAIN=local an older +# toolchain refuses to build them; 1.26.9 carried GO-2026-6809 in crypto/x509). +# Refresh with: docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: VCS stamping/tooling. ca-certificates: HTTPS module fetches. We pass # -buildvcs=false below so the .git dir need not be in the context. @@ -79,10 +80,16 @@ COPY services/bff/ ./services/bff/ # -buildvcs=false → don't stamp VCS (the .git dir isn't in the context). # -trimpath → strip absolute /workspace paths → reproducible. ### PER-SERVICE: the `./services/bff/cmd/server` build path is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/bff/cmd/server diff --git a/services/bff/cmd/server/main.go b/services/bff/cmd/server/main.go index 75e67fd..2f64708 100644 --- a/services/bff/cmd/server/main.go +++ b/services/bff/cmd/server/main.go @@ -51,6 +51,11 @@ import ( "github.com/abd-ulbasit/forgepoint/services/bff/internal/router" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // BFFConfig is the BFF's env-driven configuration. It embeds BaseConfig (Port, // GRPCPort, LogLevel, OTelEndpoint, ...) and adds the per-service addresses and // CORS origins. Defaults target the in-cluster DNS so a vanilla deploy works; @@ -125,7 +130,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "bff", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: true, diff --git a/services/bff/go.mod b/services/bff/go.mod index 6137d6d..c5d0dab 100644 --- a/services/bff/go.mod +++ b/services/bff/go.mod @@ -1,25 +1,17 @@ module github.com/abd-ulbasit/forgepoint/services/bff -go 1.25.0 +go 1.26.10 require ( - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/bff/go.sum b/services/bff/go.sum index 0ff5c3a..d27c2c2 100644 --- a/services/bff/go.sum +++ b/services/bff/go.sum @@ -1,8 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -11,58 +10,25 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/services/billing/Dockerfile b/services/billing/Dockerfile index ebc0aa3..12aa538 100644 --- a/services/billing/Dockerfile +++ b/services/billing/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/billing/ ./services/billing/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/billing/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/billing/cmd/server diff --git a/services/billing/cmd/server/main.go b/services/billing/cmd/server/main.go index ad4f0de..e38b688 100644 --- a/services/billing/cmd/server/main.go +++ b/services/billing/cmd/server/main.go @@ -68,6 +68,7 @@ import ( "net/http" "os" "os/signal" + "strings" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -90,6 +91,11 @@ import ( "github.com/abd-ulbasit/forgepoint/services/billing/internal/repository/postgres" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // depConnectTimeout bounds how long we wait, AT BOOT, for each datastore's // fail-fast ping / stream-ensure. WHY a bound: a constructor that blocks forever // on an unreachable dependency would hang the pod in a not-ready state with no @@ -139,13 +145,15 @@ type BillingConfig struct { // applies. Validated to a 3-letter uppercase code by the domain on use. DefaultCurrency string `env:"DEFAULT_CURRENCY" default:"USD"` - // RedisURL is the connection string (bare host:port) for the consumer-side - // idempotency ProcessedStore — the fast dedup layer over NATS at-least-once - // redelivery. NewClient takes an "addr", so the default is bare host:port; a - // full "redis://" URL with auth/db would need redis.ParseURL (a later concern - // when Redis grows credentials). Distinct from DatabaseURL: Redis holds only - // the short-lived dedup keys, Postgres holds the money-truth ledger + outbox. - RedisURL string `env:"REDIS_URL" default:"localhost:6379"` + // RedisURL is the connection string for the consumer-side idempotency + // ProcessedStore — the fast dedup layer over NATS at-least-once redelivery. + // Accepts either a full URL (redis:// or rediss://, parsed by redis.ParseURL, + // so credentials, db and TLS work, as ElastiCache with auth + in-transit + // encryption requires) or a bare host:port (the in-cluster Helm default). The + // default matches the local docker-compose stack, which requires a password + // (REDIS_PASSWORD, dev default "fp"). Distinct from DatabaseURL: Redis holds + // only the short-lived dedup keys, Postgres holds the ledger + outbox. + RedisURL string `env:"REDIS_URL" default:"redis://:fp@localhost:6379"` // OTelInsecure controls whether the OTLP exporter uses plaintext (no TLS). It // is true in local docker-compose (the collector has no cert) and false in @@ -209,7 +217,7 @@ func main() { slog.Int("grpc_port", cfg.GRPCPort), slog.Int("health_port", cfg.Port), slog.String("log_level", cfg.LogLevel), - slog.String("nats_url", cfg.NATSUrl), + slog.String("nats_url", config.RedactURL(cfg.NATSUrl)), slog.String("default_currency", cfg.DefaultCurrency), slog.Bool("otel_insecure", cfg.OTelInsecure), ) @@ -226,7 +234,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "billing", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: cfg.OTelInsecure, // ← from config, not hard-coded @@ -268,7 +276,13 @@ func main() { // --- Redis: the consumer-side idempotency ProcessedStore (fast dedup) --- // go-redis's Client is itself a pool; Ping fails fast on an unreachable server. - rdb := goredis.NewClient(&goredis.Options{Addr: cfg.RedisURL}) + redisOpts, err := redisOptions(cfg.RedisURL) + if err != nil { + pgPool.Close() + logger.Error("invalid FP_REDIS_URL", slog.String("error", err.Error())) + os.Exit(1) + } + rdb := goredis.NewClient(redisOpts) if err := rdb.Ping(bootCtx).Err(); err != nil { _ = rdb.Close() pgPool.Close() @@ -287,7 +301,7 @@ func main() { logger.Error("failed to connect NATS", slog.String("error", err.Error())) os.Exit(1) } - logger.Info("nats connected", slog.String("url", natsConn.ConnectedUrl())) + logger.Info("nats connected", slog.String("url", natsConn.ConnectedUrlRedacted())) // ENSURE THE STREAMS BILLING USES EXIST. JetStream silently DROPS a published // message whose subject no stream captures, and a consumer cannot attach to a @@ -880,3 +894,14 @@ func markUnwiredResolverActive(ctx context.Context, logger *slog.Logger, failClo // many money axes are degraded, and is unambiguously > 0 for the alert. counter.Add(ctx, 2) } + +// redisOptions turns FP_REDIS_URL into client options. A value with a scheme +// (redis:// or rediss://) goes through redis.ParseURL, which handles the +// password, db number and TLS. A bare host:port, which the Helm chart sets for +// the in-cluster Redis, is used as the address directly, as before. +func redisOptions(raw string) (*goredis.Options, error) { + if strings.Contains(raw, "://") { + return goredis.ParseURL(raw) + } + return &goredis.Options{Addr: raw}, nil +} diff --git a/services/billing/cmd/server/redis_options_test.go b/services/billing/cmd/server/redis_options_test.go new file mode 100644 index 0000000..a4b35ee --- /dev/null +++ b/services/billing/cmd/server/redis_options_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "net/url" + "testing" +) + +func TestRedisOptions(t *testing.T) { + t.Run("bare host:port is used as the address", func(t *testing.T) { + opts, err := redisOptions("redis.fp-infra.svc.cluster.local:6379") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Addr != "redis.fp-infra.svc.cluster.local:6379" || opts.Password != "" || opts.TLSConfig != nil { + t.Fatalf("got Addr=%q Password set=%t TLS=%t", opts.Addr, opts.Password != "", opts.TLSConfig != nil) + } + }) + t.Run("redis URL carries the password", func(t *testing.T) { + opts, err := redisOptions("redis://:s3cret@localhost:6379/2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Addr != "localhost:6379" || opts.Password != "s3cret" || opts.DB != 2 { + t.Fatalf("got Addr=%q DB=%d password match=%t", opts.Addr, opts.DB, opts.Password == "s3cret") + } + }) + t.Run("rediss URL enables TLS", func(t *testing.T) { + opts, err := redisOptions("rediss://:tok@master.example.cache.amazonaws.com:6379") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.TLSConfig == nil { + t.Fatal("expected TLS config for rediss://") + } + }) + // The ElastiCache module generates the token from [A-Za-z0-9] plus + // "!&#$^<>-". Go's net/url rejects #, ^, < and > raw in the userinfo, so + // Terraform writes the URL with urlencode(token). This pins both halves: + // the raw form fails, and the encoded form round-trips to the exact token. + t.Run("ElastiCache token must be percent-encoded in the URL", func(t *testing.T) { + const token = "Ab1!&#$^<>-z9Ab1!&#$^<>-z9" + const host = "master.example.cache.amazonaws.com:6379" + if _, err := redisOptions("rediss://:" + token + "@" + host); err == nil { + t.Fatal("expected the raw token to be rejected by net/url") + } + opts, err := redisOptions("rediss://:" + url.QueryEscape(token) + "@" + host) + if err != nil { + t.Fatalf("encoded token should parse: %v", err) + } + if opts.Password != token || opts.Addr != host || opts.TLSConfig == nil { + t.Fatalf("got Addr=%q password match=%t TLS=%t", opts.Addr, opts.Password == token, opts.TLSConfig != nil) + } + }) + t.Run("bad scheme is an error", func(t *testing.T) { + if _, err := redisOptions("http://localhost:6379"); err == nil { + t.Fatal("expected an error for a non-redis scheme") + } + }) +} diff --git a/services/billing/go.mod b/services/billing/go.mod index 752dd7b..ffa437f 100644 --- a/services/billing/go.mod +++ b/services/billing/go.mod @@ -1,42 +1,41 @@ module github.com/abd-ulbasit/forgepoint/services/billing -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 github.com/redis/go-redis/v9 v9.20.1 - go.opentelemetry.io/otel/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 - google.golang.org/grpc v1.82.1 + go.opentelemetry.io/otel/metric v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( github.com/jackc/pgx/v5 v5.10.0 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/billing/go.sum b/services/billing/go.sum index ff37975..f4d0f4c 100644 --- a/services/billing/go.sum +++ b/services/billing/go.sum @@ -6,8 +6,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -22,8 +22,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= @@ -43,69 +43,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/experiment-tracker/Dockerfile b/services/experiment-tracker/Dockerfile index f871505..e0891e1 100644 --- a/services/experiment-tracker/Dockerfile +++ b/services/experiment-tracker/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/experiment-tracker/ ./services/experiment-tracker/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/experiment-tracker/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/experiment-tracker/cmd/server diff --git a/services/experiment-tracker/cmd/server/main.go b/services/experiment-tracker/cmd/server/main.go index d41ca89..6a27023 100644 --- a/services/experiment-tracker/cmd/server/main.go +++ b/services/experiment-tracker/cmd/server/main.go @@ -84,6 +84,11 @@ import ( "github.com/abd-ulbasit/forgepoint/services/experiment-tracker/internal/repository/postgres" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // serviceName is the canonical identity used for telemetry resource attributes, // the NATS event source, and the consumer-group/DLQ naming. It MUST match // events.ServiceName (the value stamped into every published EventEnvelope.source) @@ -190,7 +195,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: serviceName, - ServiceVersion: "dev", + ServiceVersion: version, Environment: cfg.Environment, OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: cfg.OTLPInsecure, // driven by config, not hardcoded (see field comment) @@ -249,7 +254,7 @@ func main() { logger.Error("nats drain error", slog.String("error", drainErr.Error())) } }() - logger.Info("NATS connected", slog.String("url", cfg.NATSUrl)) + logger.Info("NATS connected", slog.String("url", config.RedactURL(cfg.NATSUrl))) // --- ENSURE THE EXPERIMENTS STREAM EXISTS (producer owns its stream) --- // experiment-tracker is the OWNER and sole producer of the fp.experiments.* tree diff --git a/services/experiment-tracker/go.mod b/services/experiment-tracker/go.mod index 43b3bda..177a6ea 100644 --- a/services/experiment-tracker/go.mod +++ b/services/experiment-tracker/go.mod @@ -1,40 +1,33 @@ module github.com/abd-ulbasit/forgepoint/services/experiment-tracker -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( github.com/jackc/pgx/v5 v5.10.0 - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/experiment-tracker/go.sum b/services/experiment-tracker/go.sum index 744a5b4..fe19636 100644 --- a/services/experiment-tracker/go.sum +++ b/services/experiment-tracker/go.sum @@ -3,9 +3,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -20,8 +19,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= @@ -37,65 +36,30 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/feature-store/Dockerfile b/services/feature-store/Dockerfile index f8d8bd1..62f5faa 100644 --- a/services/feature-store/Dockerfile +++ b/services/feature-store/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/feature-store/ ./services/feature-store/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/feature-store/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/feature-store/cmd/server diff --git a/services/feature-store/cmd/server/main.go b/services/feature-store/cmd/server/main.go index 1469fcd..3385e62 100644 --- a/services/feature-store/cmd/server/main.go +++ b/services/feature-store/cmd/server/main.go @@ -97,6 +97,11 @@ import ( redisrepo "github.com/abd-ulbasit/forgepoint/services/feature-store/internal/repository/redis" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // FeatureStoreConfig extends BaseConfig with feature-store-specific configuration. // // WHY embed BaseConfig: every Forgepoint service needs Port, GRPCPort, LogLevel, @@ -137,7 +142,7 @@ type FeatureStoreConfig struct { // RedisURL points at the Redis backing the online (latest-value) projection. // Format: redis://host:port/db. Injected via env (K8s ConfigMap, or a Secret if // it carries credentials). - RedisURL string `env:"REDIS_URL" default:"redis://localhost:6379"` + RedisURL string `env:"REDIS_URL" default:"redis://:fp@localhost:6379"` // OTelInsecure controls whether the OTLP exporter uses plaintext (no TLS). It is // true in local docker-compose (the collector has no cert) and false in @@ -188,7 +193,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "feature-store", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: cfg.OTelInsecure, // ← from config, not hard-coded diff --git a/services/feature-store/go.mod b/services/feature-store/go.mod index 776c745..3c0cbc9 100644 --- a/services/feature-store/go.mod +++ b/services/feature-store/go.mod @@ -1,42 +1,36 @@ module github.com/abd-ulbasit/forgepoint/services/feature-store -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 github.com/redis/go-redis/v9 v9.20.1 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( github.com/jackc/pgx/v5 v5.10.0 - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/feature-store/go.sum b/services/feature-store/go.sum index c729741..f3045ab 100644 --- a/services/feature-store/go.sum +++ b/services/feature-store/go.sum @@ -5,9 +5,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -22,8 +21,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= @@ -43,67 +42,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/inference-gateway/Dockerfile b/services/inference-gateway/Dockerfile index 656ecad..042b2bc 100644 --- a/services/inference-gateway/Dockerfile +++ b/services/inference-gateway/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/inference-gateway/ ./services/inference-gateway/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/inference-gateway/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/inference-gateway/cmd/server diff --git a/services/inference-gateway/cmd/server/main.go b/services/inference-gateway/cmd/server/main.go index bf6162a..5bf933c 100644 --- a/services/inference-gateway/cmd/server/main.go +++ b/services/inference-gateway/cmd/server/main.go @@ -78,6 +78,11 @@ import ( redisrepo "github.com/abd-ulbasit/forgepoint/services/inference-gateway/internal/repository/redis" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // serviceName is the canonical identity used for telemetry resource attributes, // the NATS event source, and the consumer-group/DLQ naming. It MUST match // events.Source (the value stamped into every published EventEnvelope.source) so @@ -114,7 +119,7 @@ type InferenceConfig struct { // RedisURL is the connection string for the rate-limit buckets, the quota // cache, and the routing-table mirror. In K8s this points at the fp-infra // Redis Service. Defaulted for local docker-compose. - RedisURL string `env:"REDIS_URL" default:"redis://localhost:6379"` + RedisURL string `env:"REDIS_URL" default:"redis://:fp@localhost:6379"` // Environment ("local"/"staging"/"production") tags telemetry and gates the // in-memory idempotency-store fallback (production REFUSES it — see below). @@ -171,7 +176,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: serviceName, - ServiceVersion: "dev", + ServiceVersion: version, Environment: cfg.Environment, OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: true, // plaintext to the local collector (no TLS cert in docker-compose) @@ -219,7 +224,7 @@ func main() { os.Exit(1) } pingCancel() - logger.Info("redis connected", slog.String("url", cfg.RedisURL)) + logger.Info("redis connected", slog.String("url", config.RedactURL(cfg.RedisURL))) // ================================================================ // 4b. NATS JetStream — the async event bus (publish + subscribe) @@ -241,7 +246,7 @@ func main() { logger.Error("nats drain error", slog.String("error", drainErr.Error())) } }() - logger.Info("NATS connected", slog.String("url", cfg.NATSUrl)) + logger.Info("NATS connected", slog.String("url", config.RedactURL(cfg.NATSUrl))) // ================================================================ // 5. CONSTRUCT ADAPTERS → PUBLISHER → DOMAIN SERVICE → HANDLER diff --git a/services/inference-gateway/go.mod b/services/inference-gateway/go.mod index e26a166..c0022c8 100644 --- a/services/inference-gateway/go.mod +++ b/services/inference-gateway/go.mod @@ -1,12 +1,12 @@ module github.com/abd-ulbasit/forgepoint/services/inference-gateway -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 github.com/redis/go-redis/v9 v9.20.1 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) @@ -19,20 +19,14 @@ require ( ) require ( - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/inference-gateway/go.sum b/services/inference-gateway/go.sum index 7f4e29d..3dc0847 100644 --- a/services/inference-gateway/go.sum +++ b/services/inference-gateway/go.sum @@ -4,9 +4,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -15,8 +14,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= @@ -33,67 +32,30 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/services/model-monitor/Dockerfile b/services/model-monitor/Dockerfile index 34c676d..3198442 100644 --- a/services/model-monitor/Dockerfile +++ b/services/model-monitor/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/model-monitor/ ./services/model-monitor/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/model-monitor/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/model-monitor/cmd/server diff --git a/services/model-monitor/cmd/server/main.go b/services/model-monitor/cmd/server/main.go index 9d4f2b8..dfe6d90 100644 --- a/services/model-monitor/cmd/server/main.go +++ b/services/model-monitor/cmd/server/main.go @@ -130,6 +130,11 @@ import ( redisrepo "github.com/abd-ulbasit/forgepoint/services/model-monitor/internal/repository/redis" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + func main() { // ================================================================ // 1. STRUCTURED LOGGER @@ -170,7 +175,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "model-monitor", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: true, // plaintext to the local collector (no TLS cert there) diff --git a/services/model-monitor/go.mod b/services/model-monitor/go.mod index 6abfef3..8cf8e68 100644 --- a/services/model-monitor/go.mod +++ b/services/model-monitor/go.mod @@ -1,42 +1,36 @@ module github.com/abd-ulbasit/forgepoint/services/model-monitor -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 github.com/redis/go-redis/v9 v9.20.1 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( github.com/jackc/pgx/v5 v5.10.0 - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/model-monitor/go.sum b/services/model-monitor/go.sum index c729741..f3045ab 100644 --- a/services/model-monitor/go.sum +++ b/services/model-monitor/go.sum @@ -5,9 +5,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -22,8 +21,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= @@ -43,67 +42,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/model-monitor/internal/config/config.go b/services/model-monitor/internal/config/config.go index 5e5b573..c16c7fa 100644 --- a/services/model-monitor/internal/config/config.go +++ b/services/model-monitor/internal/config/config.go @@ -45,7 +45,7 @@ type MonitorConfig struct { // semantics far better than a relational table. Postgres holds the durable // drift_reports; Redis holds the ephemeral in-flight windows. Optional at boot // (the scaffold doesn't connect yet) — required once the Redis adapter lands. - RedisURL string `env:"REDIS_URL" default:"redis://localhost:6379"` + RedisURL string `env:"REDIS_URL" default:"redis://:fp@localhost:6379"` // RetrainCooldown is the minimum gap between auto-retrains for one model — the // ANTI-STORM interval of the closed loop. A sustained drift breaches CRITICAL on diff --git a/services/model-serving/Dockerfile b/services/model-serving/Dockerfile index 1205dc4..43413ee 100644 --- a/services/model-serving/Dockerfile +++ b/services/model-serving/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/model-serving/ ./services/model-serving/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/model-serving/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/model-serving/cmd/server diff --git a/services/model-serving/cmd/server/main.go b/services/model-serving/cmd/server/main.go index 0030be1..9d84307 100644 --- a/services/model-serving/cmd/server/main.go +++ b/services/model-serving/cmd/server/main.go @@ -84,6 +84,11 @@ import ( "github.com/abd-ulbasit/forgepoint/services/model-serving/internal/repository/artifactstore" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // ServingConfig extends BaseConfig with model-serving-specific configuration. // // WHY embed BaseConfig: every Forgepoint service needs Port, GRPCPort, LogLevel, @@ -206,7 +211,7 @@ func main() { // ================================================================ otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "model-serving", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: true, // plaintext to the local docker-compose collector (no TLS cert) @@ -237,10 +242,10 @@ func main() { // async-bus equivalent of the gRPC graceful drain. natsConn, js, err := natsutil.Connect(cfg.NATSUrl) if err != nil { - logger.Error("failed to connect to NATS", slog.String("url", cfg.NATSUrl), slog.String("error", err.Error())) + logger.Error("failed to connect to NATS", slog.String("url", config.RedactURL(cfg.NATSUrl)), slog.String("error", err.Error())) os.Exit(1) } - logger.Info("connected to NATS JetStream", slog.String("url", cfg.NATSUrl)) + logger.Info("connected to NATS JetStream", slog.String("url", config.RedactURL(cfg.NATSUrl))) // Deferred SECOND-from-last (runs after subscribers are stopped below, before // OTel flush). Drain is graceful; if it errors we still proceed to exit. defer func() { diff --git a/services/model-serving/go.mod b/services/model-serving/go.mod index 05f1601..221ce2f 100644 --- a/services/model-serving/go.mod +++ b/services/model-serving/go.mod @@ -1,30 +1,23 @@ module github.com/abd-ulbasit/forgepoint/services/model-serving -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/model-serving/go.sum b/services/model-serving/go.sum index 72c568b..68cd61f 100644 --- a/services/model-serving/go.sum +++ b/services/model-serving/go.sum @@ -1,16 +1,15 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= @@ -19,60 +18,27 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/services/notification/Dockerfile b/services/notification/Dockerfile index d0a4200..dd99797 100644 --- a/services/notification/Dockerfile +++ b/services/notification/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/notification/ ./services/notification/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/notification/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/notification/cmd/server diff --git a/services/notification/cmd/server/main.go b/services/notification/cmd/server/main.go index 4e6037c..4beed9a 100644 --- a/services/notification/cmd/server/main.go +++ b/services/notification/cmd/server/main.go @@ -113,6 +113,11 @@ import ( redisrepo "github.com/abd-ulbasit/forgepoint/services/notification/internal/repository/redis" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // NotificationConfig extends BaseConfig with notification-specific configuration. // // WHY embed BaseConfig: every Forgepoint service needs Port, GRPCPort, LogLevel, @@ -140,7 +145,7 @@ type NotificationConfig struct { // AND the reactor's domain-level per-(event,recipient) dedup store. A redis:// // URL (host/port/db/credentials), parsed by go-redis ParseURL. // K8s: a plain ConfigMap value pointing at the in-cluster Redis. - RedisURL string `env:"REDIS_URL" default:"redis://localhost:6379"` + RedisURL string `env:"REDIS_URL" default:"redis://:fp@localhost:6379"` // SMTPAddr is the SMTP server for the EMAIL channel (host:port). Optional — // consumed by the (not-yet-written) delivery adapter; unset is valid today. @@ -183,7 +188,7 @@ func main() { slog.Int("grpc_port", cfg.GRPCPort), slog.Int("health_port", cfg.Port), slog.String("log_level", cfg.LogLevel), - slog.String("nats_url", cfg.NATSUrl), + slog.String("nats_url", config.RedactURL(cfg.NATSUrl)), slog.String("environment", cfg.Environment), ) @@ -204,7 +209,7 @@ func main() { // producer's trace context per message). otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "notification", - ServiceVersion: "dev", + ServiceVersion: version, Environment: cfg.Environment, OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: cfg.OTLPInsecure, diff --git a/services/notification/go.mod b/services/notification/go.mod index 158b091..63d1b53 100644 --- a/services/notification/go.mod +++ b/services/notification/go.mod @@ -1,42 +1,36 @@ module github.com/abd-ulbasit/forgepoint/services/notification -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 github.com/redis/go-redis/v9 v9.20.1 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( github.com/jackc/pgx/v5 v5.10.0 - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/notification/go.sum b/services/notification/go.sum index c729741..f3045ab 100644 --- a/services/notification/go.sum +++ b/services/notification/go.sum @@ -5,9 +5,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -22,8 +21,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= @@ -43,67 +42,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/pipeline-orchestrator/Dockerfile b/services/pipeline-orchestrator/Dockerfile index 5f6f6ee..7291845 100644 --- a/services/pipeline-orchestrator/Dockerfile +++ b/services/pipeline-orchestrator/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/pipeline-orchestrator/ ./services/pipeline-orchestrator/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/pipeline-orchestrator/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/pipeline-orchestrator/cmd/server diff --git a/services/pipeline-orchestrator/cmd/server/main.go b/services/pipeline-orchestrator/cmd/server/main.go index 22b7376..95c1341 100644 --- a/services/pipeline-orchestrator/cmd/server/main.go +++ b/services/pipeline-orchestrator/cmd/server/main.go @@ -97,6 +97,11 @@ import ( "github.com/abd-ulbasit/forgepoint/services/pipeline-orchestrator/internal/repository/postgres" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // depConnectTimeout bounds how long we wait, AT BOOT, for each datastore's // fail-fast ping / stream provisioning. WHY a bound: a constructor that blocks // forever on an unreachable dependency would hang the pod in a not-ready state @@ -246,7 +251,7 @@ func main() { slog.Int("grpc_port", cfg.GRPCPort), slog.Int("health_port", cfg.Port), slog.String("log_level", cfg.LogLevel), - slog.String("nats_url", cfg.NATSUrl), + slog.String("nats_url", config.RedactURL(cfg.NATSUrl)), slog.Bool("otel_insecure", cfg.OTelInsecure), slog.Duration("compensation_timeout", cfg.CompensationTimeout), ) @@ -262,7 +267,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "pipeline-orchestrator", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: cfg.OTelInsecure, // from config, not hard-coded @@ -307,7 +312,7 @@ func main() { logger.Error("failed to connect to nats", slog.String("error", err.Error())) os.Exit(1) } - logger.Info("nats connected", slog.String("url", natsConn.ConnectedUrl())) + logger.Info("nats connected", slog.String("url", natsConn.ConnectedUrlRedacted())) // Provision the JetStream streams this service depends on, idempotently. // WHY here (and why CreateOrUpdateStream): a PUBLISH only persists if some stream diff --git a/services/pipeline-orchestrator/go.mod b/services/pipeline-orchestrator/go.mod index 420c329..1789027 100644 --- a/services/pipeline-orchestrator/go.mod +++ b/services/pipeline-orchestrator/go.mod @@ -1,40 +1,33 @@ module github.com/abd-ulbasit/forgepoint/services/pipeline-orchestrator -go 1.25.0 +go 1.26.10 require ( github.com/google/uuid v1.6.0 github.com/nats-io/nats.go v1.48.0 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( github.com/jackc/pgx/v5 v5.10.0 - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/pipeline-orchestrator/go.sum b/services/pipeline-orchestrator/go.sum index 744a5b4..fe19636 100644 --- a/services/pipeline-orchestrator/go.sum +++ b/services/pipeline-orchestrator/go.sum @@ -3,9 +3,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -20,8 +19,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= @@ -37,65 +36,30 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/registry/Dockerfile b/services/registry/Dockerfile index 56e9013..02b7f96 100644 --- a/services/registry/Dockerfile +++ b/services/registry/Dockerfile @@ -69,13 +69,16 @@ # isolation is enforced at build time, not just by convention). # # ----------------------------------------------------------------------------- -# WHY golang:1.26-alpine (NOT 1.25) +# WHY golang:1.26.10-alpine (AN EXACT PATCH RELEASE) # ----------------------------------------------------------------------------- -# `go.work` declares `go 1.26.0`. The Go toolchain refuses to build a workspace -# whose go directive is NEWER than the toolchain, so a 1.25 image cannot build -# this repo (it would either error or trigger a slow toolchain auto-download). -# We pin the builder to 1.26 to match the workspace. Bump this line in lockstep -# with the `go` directive in go.work when the platform upgrades Go. +# go.work and every go.mod declare `go 1.26.10`. The image sets +# GOTOOLCHAIN=local, so the Go toolchain refuses to build modules whose go +# directive is NEWER than the toolchain: an older image errors out instead of +# compiling a binary on an unpatched standard library. The patch level matters +# because the binary's standard library IS this image's Go (1.26.9 carried +# GO-2026-6809 in crypto/x509). Bump this line (tag AND digest) in lockstep +# with the `go` directive in go.work and every go.mod, and GO_VERSION in +# .github/workflows/{ci,security}.yml. # # ----------------------------------------------------------------------------- # WHY distroless/static + nonroot for the final image @@ -106,8 +109,8 @@ # reproducible builds: the same Dockerfile resolves to the identical bytes today # and a year from now. These are multi-arch index digests, so the build works on # amd64 and arm64 hosts alike. Refresh with: -# docker buildx imagetools inspect golang:1.26-alpine -FROM golang:1.26-alpine@sha256:f1ddd9fe14fffc091dd98cb4bfa999f32c5fc77d2f2305ea9f0e2595c5437c14 AS builder +# docker buildx imagetools inspect golang:1.26.10-alpine +FROM golang:1.26.10-alpine@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder # git: some Go tooling/VCS stamping needs it. ca-certificates: for module fetches # over HTTPS. We disable VCS stamping at build time anyway (-buildvcs=false) so @@ -158,10 +161,16 @@ COPY services/registry/ ./services/registry/ # cache across rebuilds, turning a cold ~minute build into a warm few seconds. # `-o /out/service` uses a generic name so the final stage is service-agnostic. ### PER-SERVICE: the `./services/registry/cmd/server` path below is the only build arg to change. +# VERSION: CI passes the git ref name (tag or branch) as --build-arg VERSION. +# It is stamped into main.version, which the service reports as +# service.version on its traces and metrics. Before this ARG existed the +# build-arg was silently ignored and every image reported "dev". Declared +# after the dependency layers so a new version only re-runs the compile step. +ARG VERSION=dev ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -ldflags="-s -w" -buildvcs=false -trimpath \ + go build -ldflags="-s -w -X main.version=${VERSION}" -buildvcs=false -trimpath \ -o /out/service ./services/registry/cmd/server diff --git a/services/registry/cmd/server/main.go b/services/registry/cmd/server/main.go index d49cfab..ef5e331 100644 --- a/services/registry/cmd/server/main.go +++ b/services/registry/cmd/server/main.go @@ -67,6 +67,7 @@ import ( "net/http" "os" "os/signal" + "strings" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -86,6 +87,11 @@ import ( redisstore "github.com/abd-ulbasit/forgepoint/services/registry/internal/repository/redis" ) +// version is set at build time with -ldflags "-X main.version=" (see the +// Dockerfile's VERSION build arg) and reported as the OTel service.version. +// Local `go run` builds report "dev". +var version = "dev" + // depConnectTimeout bounds how long we wait, AT BOOT, for each datastore's // fail-fast ping. WHY a bound: a constructor that blocks forever on an // unreachable dependency would hang the pod in a not-ready state with no signal; @@ -107,11 +113,12 @@ type RegistryConfig struct { // RedisURL is the connection string for the CQRS READ projection (Redis). It is // distinct from DatabaseURL (the Postgres WRITE store) precisely because the two - // stores are separate in CQRS. NewReadStore takes a host:port "addr" (not a URL), - // so the default is bare host:port and we pass it through directly. A full - // "redis://" URL with auth/db would need redis.ParseURL — a later concern when - // Redis grows credentials; bare addr is correct for the local/in-cluster stack. - RedisURL string `env:"REDIS_URL" default:"localhost:6379"` + // stores are separate in CQRS. Accepts either a full URL (redis:// or rediss://, + // parsed by redis.ParseURL, so credentials, db and TLS work, as ElastiCache with + // auth + in-transit encryption requires) or a bare host:port (the in-cluster + // Helm default). The default matches the local docker-compose stack, which + // requires a password (REDIS_PASSWORD, dev default "fp"). + RedisURL string `env:"REDIS_URL" default:"redis://:fp@localhost:6379"` // OTelInsecure controls whether the OTLP exporter uses plaintext (no TLS). It is // true in local docker-compose (the collector has no cert) and false in @@ -174,7 +181,7 @@ func main() { slog.Int("grpc_port", cfg.GRPCPort), slog.Int("health_port", cfg.Port), slog.String("log_level", cfg.LogLevel), - slog.String("nats_url", cfg.NATSUrl), + slog.String("nats_url", config.RedactURL(cfg.NATSUrl)), slog.Bool("otel_insecure", cfg.OTelInsecure), ) @@ -189,7 +196,7 @@ func main() { otelShutdown, err := observability.Setup(ctx, observability.Config{ ServiceName: "registry", - ServiceVersion: "dev", + ServiceVersion: version, Environment: "local", OTLPEndpoint: cfg.OTelEndpoint, OTLPInsecure: cfg.OTelInsecure, // ← from config, not hard-coded (see RegistryConfig) @@ -237,7 +244,13 @@ func main() { // --- Redis: the CQRS READ projection (eventually-consistent query side) --- // go-redis's Client is itself a pool; Ping fails fast on an unreachable server. - rdb := goredis.NewClient(&goredis.Options{Addr: cfg.RedisURL}) + redisOpts, err := redisOptions(cfg.RedisURL) + if err != nil { + pgPool.Close() + logger.Error("invalid FP_REDIS_URL", slog.String("error", err.Error())) + os.Exit(1) + } + rdb := goredis.NewClient(redisOpts) if err := rdb.Ping(bootCtx).Err(); err != nil { _ = rdb.Close() pgPool.Close() @@ -259,7 +272,7 @@ func main() { logger.Error("failed to connect NATS", slog.String("error", err.Error())) os.Exit(1) } - logger.Info("nats connected", slog.String("url", natsConn.ConnectedUrl())) + logger.Info("nats connected", slog.String("url", natsConn.ConnectedUrlRedacted())) // --- ENSURE THE MODELS STREAM EXISTS (producer owns its stream) --- // The registry is the OWNER and sole producer of the fp.models.* lifecycle tree. @@ -541,3 +554,14 @@ func main() { shutdown() logger.Info("registry service stopped cleanly") } + +// redisOptions turns FP_REDIS_URL into client options. A value with a scheme +// (redis:// or rediss://) goes through redis.ParseURL, which handles the +// password, db number and TLS. A bare host:port, which the Helm chart sets for +// the in-cluster Redis, is used as the address directly, as before. +func redisOptions(raw string) (*goredis.Options, error) { + if strings.Contains(raw, "://") { + return goredis.ParseURL(raw) + } + return &goredis.Options{Addr: raw}, nil +} diff --git a/services/registry/cmd/server/redis_options_test.go b/services/registry/cmd/server/redis_options_test.go new file mode 100644 index 0000000..a4b35ee --- /dev/null +++ b/services/registry/cmd/server/redis_options_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "net/url" + "testing" +) + +func TestRedisOptions(t *testing.T) { + t.Run("bare host:port is used as the address", func(t *testing.T) { + opts, err := redisOptions("redis.fp-infra.svc.cluster.local:6379") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Addr != "redis.fp-infra.svc.cluster.local:6379" || opts.Password != "" || opts.TLSConfig != nil { + t.Fatalf("got Addr=%q Password set=%t TLS=%t", opts.Addr, opts.Password != "", opts.TLSConfig != nil) + } + }) + t.Run("redis URL carries the password", func(t *testing.T) { + opts, err := redisOptions("redis://:s3cret@localhost:6379/2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Addr != "localhost:6379" || opts.Password != "s3cret" || opts.DB != 2 { + t.Fatalf("got Addr=%q DB=%d password match=%t", opts.Addr, opts.DB, opts.Password == "s3cret") + } + }) + t.Run("rediss URL enables TLS", func(t *testing.T) { + opts, err := redisOptions("rediss://:tok@master.example.cache.amazonaws.com:6379") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.TLSConfig == nil { + t.Fatal("expected TLS config for rediss://") + } + }) + // The ElastiCache module generates the token from [A-Za-z0-9] plus + // "!&#$^<>-". Go's net/url rejects #, ^, < and > raw in the userinfo, so + // Terraform writes the URL with urlencode(token). This pins both halves: + // the raw form fails, and the encoded form round-trips to the exact token. + t.Run("ElastiCache token must be percent-encoded in the URL", func(t *testing.T) { + const token = "Ab1!&#$^<>-z9Ab1!&#$^<>-z9" + const host = "master.example.cache.amazonaws.com:6379" + if _, err := redisOptions("rediss://:" + token + "@" + host); err == nil { + t.Fatal("expected the raw token to be rejected by net/url") + } + opts, err := redisOptions("rediss://:" + url.QueryEscape(token) + "@" + host) + if err != nil { + t.Fatalf("encoded token should parse: %v", err) + } + if opts.Password != token || opts.Addr != host || opts.TLSConfig == nil { + t.Fatalf("got Addr=%q password match=%t TLS=%t", opts.Addr, opts.Password == token, opts.TLSConfig != nil) + } + }) + t.Run("bad scheme is an error", func(t *testing.T) { + if _, err := redisOptions("http://localhost:6379"); err == nil { + t.Fatal("expected an error for a non-redis scheme") + } + }) +} diff --git a/services/registry/go.mod b/services/registry/go.mod index 5aff1d8..5b5295a 100644 --- a/services/registry/go.mod +++ b/services/registry/go.mod @@ -1,39 +1,32 @@ module github.com/abd-ulbasit/forgepoint/services/registry -go 1.25.0 +go 1.26.10 require ( github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats.go v1.48.0 github.com/redis/go-redis/v9 v9.20.1 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect ) diff --git a/services/registry/go.sum b/services/registry/go.sum index c729741..f3045ab 100644 --- a/services/registry/go.sum +++ b/services/registry/go.sum @@ -5,9 +5,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -22,8 +21,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= @@ -43,67 +42,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/web/package-lock.json b/web/package-lock.json index 85241b1..32bb8b3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.59.0", - "axios": "^1.7.7", + "axios": "^1.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", @@ -1499,13 +1499,13 @@ } }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2083,9 +2083,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/web/package.json b/web/package.json index e8469ac..8809c31 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.59.0", - "axios": "^1.7.7", + "axios": "^1.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0",