diff --git a/.dockerignore b/.dockerignore index d071a0b..2085b21 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ test.yaml bin -.DS_Store \ No newline at end of file +.DS_Store +.cursor +AGENTS.md \ No newline at end of file diff --git a/.github/actions/set-build-env/action.yml b/.github/actions/set-build-env/action.yml new file mode 100644 index 0000000..cb8df65 --- /dev/null +++ b/.github/actions/set-build-env/action.yml @@ -0,0 +1,49 @@ +name: Set NATS build environment +description: Resolves NATS image registry, container image, OCI source, distribution, and GitHub repo from workflow env or github.repository. + +runs: + using: composite + steps: + - name: Set image registry and OCI source + shell: bash + run: | + REGISTRY="${{ env.IMAGE_REGISTRY }}" + OCI_SOURCE="${{ env.OCI_SOURCE_REPO }}" + NATS_IMAGE="${{ env.NATS_CONTAINER_IMAGE }}" + if [ -z "$REGISTRY" ] || [ -z "$OCI_SOURCE" ] || [ -z "$NATS_IMAGE" ]; then + case "${{ github.repository }}" in + eclipse-iofog/nats-server) + REGISTRY="${REGISTRY:-ghcr.io/eclipse-iofog}" + OCI_SOURCE="${OCI_SOURCE:-https://github.com/eclipse-iofog/nats-server}" + NATS_IMAGE="${NATS_IMAGE:-ghcr.io/eclipse-iofog/nats}" + ;; + *) + REGISTRY="${REGISTRY:-ghcr.io/datasance}" + OCI_SOURCE="${OCI_SOURCE:-https://github.com/Datasance/nats-server}" + NATS_IMAGE="${NATS_IMAGE:-ghcr.io/datasance/nats}" + ;; + esac + fi + echo "IMAGE_REGISTRY=$REGISTRY" >> "${GITHUB_ENV}" + echo "OCI_SOURCE_REPO=$OCI_SOURCE" >> "${GITHUB_ENV}" + echo "NATS_CONTAINER_IMAGE=$NATS_IMAGE" >> "${GITHUB_ENV}" + + - name: Set NATS distribution + shell: bash + run: | + DISTRIBUTION="${{ env.NATS_DISTRIBUTION }}" + GITHUB_REPO="${{ env.NATS_GITHUB_REPO }}" + if [ -z "$DISTRIBUTION" ] || [ -z "$GITHUB_REPO" ]; then + case "${{ github.repository }}" in + eclipse-iofog/nats-server) + DISTRIBUTION="${DISTRIBUTION:-iofog}" + GITHUB_REPO="${GITHUB_REPO:-eclipse-iofog/nats-server}" + ;; + *) + DISTRIBUTION="${DISTRIBUTION:-datasance}" + GITHUB_REPO="${GITHUB_REPO:-Datasance/nats-server}" + ;; + esac + fi + echo "NATS_DISTRIBUTION=$DISTRIBUTION" >> "${GITHUB_ENV}" + echo "NATS_GITHUB_REPO=$GITHUB_REPO" >> "${GITHUB_ENV}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b6087bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + pull_request: + branches: [develop] + paths-ignore: + - README.md + - CHANGELOG.md + - LICENSE + push: + branches: [develop] + paths-ignore: + - README.md + - CHANGELOG.md + - LICENSE + workflow_dispatch: + +permissions: read-all + +env: + GO_VERSION: '1.26.4' + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: go.sum + + - run: go version + + - name: golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.12.2 + args: --timeout=5m0s --config .golangci.yaml + + - name: Static security analysis + run: make security-code + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: go.sum + + - run: go version + + - name: Run unit tests + run: make test + + - name: Check formatting + run: make fmt-check + + docker-build-smoke: + name: Docker build smoke + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: ./.github/actions/set-build-env + env: + IMAGE_REGISTRY: ${{ vars.IMAGE_REGISTRY }} + NATS_CONTAINER_IMAGE: ${{ vars.NATS_CONTAINER_IMAGE }} + OCI_SOURCE_REPO: ${{ vars.OCI_SOURCE_REPO }} + NATS_DISTRIBUTION: ${{ vars.NATS_DISTRIBUTION }} + NATS_GITHUB_REPO: ${{ vars.NATS_GITHUB_REPO }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Build UBI image (amd64, arm64) + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ env.NATS_CONTAINER_IMAGE }}:ci + + - name: Build edge image (arm/v7, riscv64) + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: Dockerfile.edge + platforms: linux/arm/v7,linux/riscv64 + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ env.NATS_CONTAINER_IMAGE }}:ci-edge diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..670a710 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,28 @@ +name: govulncheck + +on: + schedule: + - cron: "0 0 * * 0" + workflow_dispatch: {} + +permissions: read-all + +env: + GO_VERSION: '1.26.4' + +jobs: + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: go.sum + + - name: Run govulncheck + run: make vulncheck diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml deleted file mode 100644 index 6cfb9c5..0000000 --- a/.github/workflows/push.yaml +++ /dev/null @@ -1,86 +0,0 @@ -name: CI -on: - push: - branches: - - main - tags: [v*] - paths-ignore: - - README.md - - CHANGELOG.md - - LICENSE - pull_request: - # Sequence of patterns matched against refs/heads - branches: - - main - paths-ignore: - - README.md - - CHANGELOG.md - - LICENSE -env: - IMAGE_NAME: 'nats' - -jobs: - build: - runs-on: ubuntu-22.04 - permissions: - actions: write - checks: write - contents: write - deployments: write - id-token: write - issues: write - discussions: write - packages: write - pages: write - pull-requests: write - repository-projects: write - security-events: write - statuses: write - name: Build and Publish - steps: - - uses: actions/checkout@v4 - - name: 'Get Previous tag' - id: previoustag - uses: "WyriHaximus/github-action-get-previous-tag@v1" - with: - fallback: 0.0.0 - - name: Set image tag - shell: bash - id: tags - run: | - if [[ ${{ github.ref_name }} =~ ^v.* ]] ; then - VERSION=${{ github.ref_name }} - echo "VERSION=${VERSION:1}" >> "${GITHUB_OUTPUT}" - else - VERSION=${{ steps.previoustag.outputs.tag }} - echo "VERSION=${VERSION:1}-${{ github.run_number }}" >> "${GITHUB_OUTPUT}" - fi - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Github Container Registry - - uses: docker/login-action@v3 - with: - registry: "ghcr.io" - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and Push to ghcr - - uses: docker/build-push-action@v5 - id: build_push_ghcr - with: - context: . - file: Dockerfile - platforms: linux/amd64, linux/arm64 - push: true - tags: | - ghcr.io/eclipse-iofog/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.VERSION }} - ghcr.io/eclipse-iofog/${{ env.IMAGE_NAME }}:latest - ghcr.io/eclipse-iofog/${{ env.IMAGE_NAME }}:main - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5c003e7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,196 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + GO_VERSION: '1.26.4' + +permissions: read-all + +jobs: + quality: + name: Lint and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: go.sum + + - run: go version + + - name: golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.12.2 + args: --timeout=5m0s --config .golangci.yaml + + - name: Static security analysis + run: make security-code + + - name: Run unit tests + run: make test + + - name: Check formatting + run: make fmt-check + + publish-ubi: + name: Publish UBI image (amd64, arm64) + runs-on: ubuntu-latest + needs: [quality] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: ./.github/actions/set-build-env + env: + IMAGE_REGISTRY: ${{ vars.IMAGE_REGISTRY }} + NATS_CONTAINER_IMAGE: ${{ vars.NATS_CONTAINER_IMAGE }} + OCI_SOURCE_REPO: ${{ vars.OCI_SOURCE_REPO }} + NATS_DISTRIBUTION: ${{ vars.NATS_DISTRIBUTION }} + NATS_GITHUB_REPO: ${{ vars.NATS_GITHUB_REPO }} + + - name: Resolve semver tag + run: | + SEMVER="${GITHUB_REF_NAME#v}" + echo "SEMVER=${SEMVER}" >> "${GITHUB_ENV}" + REGISTRY_HOST="${NATS_CONTAINER_IMAGE%%/*}" + echo "REGISTRY_HOST=${REGISTRY_HOST}" >> "${GITHUB_ENV}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to container registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.6.0 + with: + registry: ${{ env.REGISTRY_HOST }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Internal staging tag; merged into :semver/:latest/:main by publish-manifest. + - name: Build and push UBI image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + OCI_SOURCE_REPO=${{ env.OCI_SOURCE_REPO }} + OCI_VERSION=${{ env.SEMVER }} + OCI_REVISION=${{ github.sha }} + NATS_DISTRIBUTION=${{ env.NATS_DISTRIBUTION }} + tags: ${{ env.NATS_CONTAINER_IMAGE }}:${{ env.SEMVER }}-ubi + + publish-edge: + name: Publish edge image (arm/v7, riscv64) + runs-on: ubuntu-latest + needs: [quality] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: ./.github/actions/set-build-env + env: + IMAGE_REGISTRY: ${{ vars.IMAGE_REGISTRY }} + NATS_CONTAINER_IMAGE: ${{ vars.NATS_CONTAINER_IMAGE }} + OCI_SOURCE_REPO: ${{ vars.OCI_SOURCE_REPO }} + NATS_DISTRIBUTION: ${{ vars.NATS_DISTRIBUTION }} + NATS_GITHUB_REPO: ${{ vars.NATS_GITHUB_REPO }} + + - name: Resolve semver tag + run: | + SEMVER="${GITHUB_REF_NAME#v}" + echo "SEMVER=${SEMVER}" >> "${GITHUB_ENV}" + REGISTRY_HOST="${NATS_CONTAINER_IMAGE%%/*}" + echo "REGISTRY_HOST=${REGISTRY_HOST}" >> "${GITHUB_ENV}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to container registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.6.0 + with: + registry: ${{ env.REGISTRY_HOST }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Internal staging tag; merged into :semver/:latest/:main by publish-manifest. + - name: Build and push edge image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: Dockerfile.edge + platforms: linux/arm/v7,linux/riscv64 + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + OCI_SOURCE_REPO=${{ env.OCI_SOURCE_REPO }} + OCI_VERSION=${{ env.SEMVER }} + OCI_REVISION=${{ github.sha }} + NATS_DISTRIBUTION=${{ env.NATS_DISTRIBUTION }} + tags: ${{ env.NATS_CONTAINER_IMAGE }}:${{ env.SEMVER }}-edge + + publish-manifest: + name: Merge 4-platform manifest + runs-on: ubuntu-latest + needs: [publish-ubi, publish-edge] + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: ./.github/actions/set-build-env + env: + IMAGE_REGISTRY: ${{ vars.IMAGE_REGISTRY }} + NATS_CONTAINER_IMAGE: ${{ vars.NATS_CONTAINER_IMAGE }} + OCI_SOURCE_REPO: ${{ vars.OCI_SOURCE_REPO }} + NATS_DISTRIBUTION: ${{ vars.NATS_DISTRIBUTION }} + NATS_GITHUB_REPO: ${{ vars.NATS_GITHUB_REPO }} + + - name: Resolve semver tag + run: | + SEMVER="${GITHUB_REF_NAME#v}" + echo "SEMVER=${SEMVER}" >> "${GITHUB_ENV}" + REGISTRY_HOST="${NATS_CONTAINER_IMAGE%%/*}" + echo "REGISTRY_HOST=${REGISTRY_HOST}" >> "${GITHUB_ENV}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to container registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.6.0 + with: + registry: ${{ env.REGISTRY_HOST }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Combine UBI (amd64, arm64) + edge (arm/v7, riscv64) into public release tags. + - name: Create unified manifest + run: | + docker buildx imagetools create \ + -t "${NATS_CONTAINER_IMAGE}:${SEMVER}" \ + -t "${NATS_CONTAINER_IMAGE}:latest" \ + -t "${NATS_CONTAINER_IMAGE}:main" \ + "${NATS_CONTAINER_IMAGE}:${SEMVER}-ubi" \ + "${NATS_CONTAINER_IMAGE}:${SEMVER}-edge" diff --git a/.gitignore b/.gitignore index 50ca0bf..f2ca98b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ test.yaml bin/ -.DS_Store \ No newline at end of file +.DS_Store +.cursor +AGENTS.md diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..00bed72 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,79 @@ +# golangci-lint configuration for nats-server wrapper +# Target: Go 1.26.4, module github.com/eclipse-iofog/nats-server + +version: "2" + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - ^\.cursor/ + - ^build/ + - ^vendor/ + +linters: + default: none + enable: + - govet + - revive + - staticcheck + - errcheck + - misspell + - errorlint + settings: + revive: + enable-all-rules: true + rules: + - {name: add-constant, disabled: true} + - {name: argument-limit, disabled: true} + - {name: cognitive-complexity, disabled: true} + - {name: confusing-naming, disabled: true} + - {name: confusing-results, disabled: true} + - {name: cyclomatic, disabled: true} + - {name: early-return, disabled: true} + - {name: empty-block, disabled: true} + - {name: enforce-switch-style, disabled: true} + - {name: flag-parameter, disabled: true} + - {name: function-length, disabled: true} + - {name: function-result-limit, disabled: true} + - {name: import-shadowing, disabled: true} + - {name: line-length-limit, disabled: true} + - {name: max-control-nesting, disabled: true} + - {name: max-public-structs, disabled: true} + - {name: redundant-import-alias, disabled: true} + - {name: unsecure-url-scheme, disabled: true} + - {name: unused-parameter, disabled: true} + - {name: unused-receiver, disabled: true} + - {name: use-waitgroup-go, disabled: true} + - {name: var-naming, disabled: true} + staticcheck: + checks: + - all + - -SA1019 + misspell: + locale: US + errorlint: + errorf: true + asserts: true + comparison: true + exclusions: + generated: lax + paths: + - ^\.cursor/ + - ^build/ + - ^vendor/ + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + +run: + timeout: 5m diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..33bbc4d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.14.2] - 2026-06-15 + +### Added + +- Root `NOTICE` (EPL-2.0); per-file copyright blocks removed from Go sources. +- golangci-lint v2, unit tests, and Makefile quality targets (`lint`, `fmt-check`, `vulncheck`, `security-code`). +- CI workflow (`.github/workflows/ci.yml`) — lint, test, and Docker build smoke on `develop` (4 platforms, no registry push). +- Weekly `govulncheck` workflow (`.github/workflows/govulncheck.yml`). +- `.github/actions/set-build-env` composite action for dual-mirror registry and OCI label variables. +- Release workflow (`.github/workflows/release.yml`) — multi-arch GHCR publish on `v*` tags only. +- `Dockerfile.edge` for **linux/arm/v7** and **linux/riscv64** (Alpine 3.22 runtime). +- Multi-arch manifest merge (`:2.14.2`, `:latest`, `:main` on each release). + +### Changed + +- Embedded **nats-server v2.14.2** (from v2.12.x lineage). +- Go toolchain **1.26.4** (`go.mod`, builder stages, CI). +- **`NATS_TLS_DIR`** is the primary TLS directory env var; **`NATS_SSL_DIR`** remains as a deprecated fallback when `NATS_TLS_DIR` is unset. +- UBI production image slimmed: static `CGO_ENABLED=0` binaries; runtime adds only `ca-certificates` and `tzdata`. +- Renamed `Dockerfile-dev` → `Dockerfile.dev` (local debug image with nats-cli; not published). +- Replaced legacy push-on-every-branch CI with develop checks + tag-only release publish. + +### Removed + +- **nats-cli** from production container images. +- curl, grep, and copied OpenSSL `.so` libraries from UBI runtime image. +- Legacy `.github/workflows/push.yaml`. + +### Security + +- SHA-pinned GitHub Actions and Docker base images. +- `govulncheck` and `gosec` integrated into CI and Makefile. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8fc5170 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to NATS Server wrapper + +Thank you for contributing to the **ioFog NATS wrapper image** (`github.com/eclipse-iofog/nats-server`). The wrapper binary is **`iofog-nats`**; it execs upstream **nats-server v2.14.2** as a child process with config watch, JWT sync, JetStream reconcile, and claims push. + +## Dual-mirror workflow + +The same codebase is maintained on two GitHub remotes. CI variables select the registry and OCI labels per repo; wrapper code does **not** branch on product name. + +| Variable | [eclipse-iofog/nats-server](https://github.com/eclipse-iofog/nats-server) | [Datasance/nats-server](https://github.com/Datasance/nats-server) | +|----------|---------------------------------------------------------------------------|-------------------------------------------------------------------| +| `IMAGE_REGISTRY` | `ghcr.io/eclipse-iofog` | `ghcr.io/datasance` | +| `NATS_CONTAINER_IMAGE` | `ghcr.io/eclipse-iofog/nats` | `ghcr.io/datasance/nats` | +| `OCI_SOURCE_REPO` | `https://github.com/eclipse-iofog/nats-server` | `https://github.com/Datasance/nats-server` | +| `NATS_DISTRIBUTION` | `iofog` | `datasance` | +| `NATS_GITHUB_REPO` | `eclipse-iofog/nats-server` | `Datasance/nats-server` | + +Set these under **Settings → Actions → Variables** on each repo (Plan 8 bootstrap). When unset, [`.github/actions/set-build-env`](.github/actions/set-build-env/action.yml) derives defaults from `github.repository`. + +**Development flow:** implement on **`Datasance/nats-server` `develop`**, open a PR to **`eclipse-iofog/nats-server` `develop`**. Integration branch is **`develop`** (not `main` for daily work). + +### Remotes (developer clone) + +```bash +git remote add upstream https://github.com/eclipse-iofog/nats-server.git +git remote add datasance https://github.com/Datasance/nats-server.git +``` + +Push feature branches to **datasance**; merge target upstream is **upstream develop**. + +## Branch naming + +Feature branches: **`nats-server/-`** (e.g. `nats-server/03-ci`, `nats-server/07-docs`). + +## Required gates + +Run from the repository root before opening a pull request: + +```bash +make fmt-check +make lint # golangci-lint — zero issues required +make test +make security-code # gosec on ./cmd/... ./internal/... +make vulncheck # govulncheck + go mod verify +``` + +CI runs the same gates on PR and push to **`develop`**, plus a Docker build smoke test for UBI (amd64/arm64) and edge (arm/v7, riscv64) with **`push: false`**. + +## Pull requests + +1. Target **`develop`** with a focused description and test notes. +2. Update **CHANGELOG.md** under `[Unreleased]` for user-facing changes that ship before the next tagged release. +3. Preserve wrapper integration contracts documented in [README.md](README.md): child exec of nats-server, SIGHUP reload (leaf: SIGHUP for TLS only, SIGINT+restart otherwise), JWT sync, JetStream purge, claims push, and the **`NATS_*`** env contract. +4. Do **not** add product-specific runtime env vars (`IOFOG_*`, `POT_*`, `NATS_DISTRIBUTION` in Go code). +5. Do **not** rename the workload label **`iofog-nats`** or the wrapper binary name. + +## Updating digest-pinned dependencies + +### Docker base images + +Production Dockerfiles pin base images by digest: + +- **`Dockerfile`** — `golang:1.26.4-alpine`, UBI 9 minimal/micro (amd64/arm64). +- **`Dockerfile.edge`** — `golang:1.26.4-alpine`, `alpine:3.22` (arm/v7, riscv64). + +When bumping a base image: + +1. Pull the new tag and note its **`sha256:`** digest. +2. Update the **`FROM ...@sha256:...`** line in the relevant Dockerfile(s). +3. Update the matching comment line above each `FROM` (edgelet pattern — digest documented in comment and pin). +4. Rebuild locally: `make docker-build` and/or `docker buildx build -f Dockerfile.edge ...`. +5. Confirm CI docker-build-smoke job passes on your PR. + +### GitHub Actions + +Workflows SHA-pin third-party actions with a version comment (e.g. `# v4`). When upgrading an action: + +1. Find the release tag’s full commit SHA on GitHub. +2. Replace `uses: org/action@ # vX.Y.Z` in `.github/workflows/*.yml`. +3. Keep **`permissions: read-all`** on CI workflows; release publish job alone uses **`packages: write`**. + +### Go module and embedded nats-server + +- Bump **`go`** in `go.mod` and **`GO_VERSION`** in workflows together. +- Pin embedded server with `go install github.com/nats-io/nats-server/v2@vX.Y.Z` in Dockerfiles; align git tag and CHANGELOG with that version. + +## Release procedure (maintainers) + +Container images publish **only** on **`v*`** git tags (not ordinary `develop` pushes). For dual-mirror releases: + +1. Merge to **`develop`** on both remotes at the **same SHA**. +2. Create identical annotated tags (e.g. **`v2.14.2`**) on both remotes and push. +3. Verify both **release.yml** workflows succeed and tags `:semver`, `:latest`, `:main` exist on each registry. + +Record published image digests in CHANGELOG release notes when tagging. + +## Security + +Report vulnerabilities privately — do not file public issues for exploitable findings. + +## Questions + +Open a GitHub issue for bugs and feature discussion. diff --git a/Dockerfile b/Dockerfile index 99ac931..72988c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ # Build iofog-nats wrapper and install nats-server -FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS go-builder +# golang:1.26.4-alpine — sha256:9169234cc43b396435c64e45538fe6d4ffa237e7f988b9ab32abdfa0c3141979 +FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine@sha256:9169234cc43b396435c64e45538fe6d4ffa237e7f988b9ab32abdfa0c3141979 AS go-builder ARG TARGETOS ARG TARGETARCH ARG BUILDPLATFORM @@ -7,55 +8,47 @@ ARG BUILDPLATFORM WORKDIR /build COPY . . +ENV CGO_ENABLED=0 RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o iofog-nats ./cmd/iofog-nats - -RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/nats-io/nats-server/v2@v2.12.4 - +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/nats-io/nats-server/v2@v2.14.2 RUN mkdir -p /out && \ find /go/bin -name "nats-server" -type f -exec cp {} /out/nats-server \; # Create non-root user and writable dirs for pid file and JetStream store -FROM registry.access.redhat.com/ubi9/ubi-minimal:latest AS user-stage +# ubi9/ubi-minimal — sha256:1ae81b51dcbb0a1cd6a59b3d42b52f98201778c7cd6e9765194574258d3f29de +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:1ae81b51dcbb0a1cd6a59b3d42b52f98201778c7cd6e9765194574258d3f29de AS user-stage RUN microdnf install -y ca-certificates shadow-utils && microdnf install -y tzdata && microdnf reinstall -y tzdata && microdnf clean all -y RUN useradd --uid 10000 --create-home runner RUN mkdir -p /home/runner/run /home/runner/data /home/runner/bin /home/runner/nats/jwt && chown -R runner:runner /home/runner # Stage runtime files so final image can use a single COPY layer -FROM registry.access.redhat.com/ubi9/ubi-minimal:latest AS runtime-staging +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:1ae81b51dcbb0a1cd6a59b3d42b52f98201778c7cd6e9765194574258d3f29de AS runtime-staging COPY --from=user-stage /etc/passwd /staging/etc/passwd COPY --from=user-stage /etc/group /staging/etc/group COPY --from=user-stage /home/runner /staging/home/runner -COPY --from=user-stage /usr/bin/grep /staging/usr/bin/grep -COPY --from=user-stage /usr/bin/curl /staging/usr/bin/curl COPY --from=user-stage /etc/ssl/certs/ca-bundle.crt /staging/etc/ssl/certs/ca-bundle.crt COPY --from=user-stage /etc/pki/tls/certs/ca-bundle.crt /staging/etc/pki/tls/certs/ca-bundle.crt COPY --from=user-stage /usr/share/zoneinfo /staging/usr/share/zoneinfo -COPY --from=user-stage /usr/lib64/libcurl.so.4 /staging/usr/lib64/libcurl.so.4 -COPY --from=user-stage /usr/lib64/libc.so.6 /staging/usr/lib64/libc.so.6 -COPY --from=user-stage /usr/lib64/libnghttp2.so.14 /staging/usr/lib64/libnghttp2.so.14 -COPY --from=user-stage /usr/lib64/libssl.so.3 /staging/usr/lib64/libssl.so.3 -COPY --from=user-stage /usr/lib64/libcrypto.so.3 /staging/usr/lib64/libcrypto.so.3 -COPY --from=user-stage /usr/lib64/libgssapi_krb5.so.2 /staging/usr/lib64/libgssapi_krb5.so.2 -COPY --from=user-stage /usr/lib64/libkrb5.so.3 /staging/usr/lib64/libkrb5.so.3 -COPY --from=user-stage /usr/lib64/libk5crypto.so.3 /staging/usr/lib64/libk5crypto.so.3 -COPY --from=user-stage /usr/lib64/libcom_err.so.2 /staging/usr/lib64/libcom_err.so.2 -COPY --from=user-stage /usr/lib64/libz.so.1 /staging/usr/lib64/libz.so.1 -COPY --from=user-stage /usr/lib64/libkrb5support.so.0 /staging/usr/lib64/libkrb5support.so.0 -COPY --from=user-stage /usr/lib64/libkeyutils.so.1 /staging/usr/lib64/libkeyutils.so.1 -COPY --from=user-stage /usr/lib64/libresolv.so.2 /staging/usr/lib64/libresolv.so.2 -COPY --from=user-stage /usr/lib64/libselinux.so.1 /staging/usr/lib64/libselinux.so.1 -COPY --from=user-stage /usr/lib64/libpcre2-8.so.0 /staging/usr/lib64/libpcre2-8.so.0 -COPY --from=user-stage /usr/lib64/libpcre.so.1 /staging/usr/lib64/libpcre.so.1 -COPY --from=user-stage /usr/lib64/libsigsegv.so.2 /staging/usr/lib64/libsigsegv.so.2 # Final image: UBI 9 micro -FROM registry.access.redhat.com/ubi9/ubi-micro:latest +# ubi9/ubi-micro — sha256:59daac603227814ee7fe5cde69ac5ec2815b361c7cb1b2bc9ed3f55673499d38 +FROM registry.access.redhat.com/ubi9/ubi-micro@sha256:59daac603227814ee7fe5cde69ac5ec2815b361c7cb1b2bc9ed3f55673499d38 + +ARG OCI_SOURCE_REPO +ARG OCI_VERSION +ARG OCI_REVISION +ARG NATS_DISTRIBUTION + +LABEL org.opencontainers.image.source="${OCI_SOURCE_REPO}" \ + org.opencontainers.image.version="${OCI_VERSION}" \ + org.opencontainers.image.revision="${OCI_REVISION}" \ + distribution="${NATS_DISTRIBUTION}" + COPY --from=runtime-staging /staging/ / -# Copy from the normalized /out directory COPY --from=go-builder /build/iofog-nats /home/runner/bin/iofog-nats COPY --from=go-builder /out/nats-server /home/runner/bin/nats-server @@ -64,4 +57,4 @@ COPY LICENSE /licenses/LICENSE USER 10000 WORKDIR /home/runner -CMD ["/home/runner/bin/iofog-nats"] \ No newline at end of file +CMD ["/home/runner/bin/iofog-nats"] diff --git a/Dockerfile-dev b/Dockerfile.dev similarity index 97% rename from Dockerfile-dev rename to Dockerfile.dev index 8d81d76..d0d7014 100644 --- a/Dockerfile-dev +++ b/Dockerfile.dev @@ -1,5 +1,5 @@ # Build iofog-nats wrapper and install nats-server + nats-cli -FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS go-builder +FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine AS go-builder ARG TARGETOS ARG TARGETARCH ARG BUILDPLATFORM @@ -11,7 +11,7 @@ COPY . . RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o iofog-nats ./cmd/iofog-nats -RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/nats-io/nats-server/v2@v2.12.4 +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/nats-io/nats-server/v2@v2.14.2 RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/nats-io/natscli/nats@latest diff --git a/Dockerfile.edge b/Dockerfile.edge new file mode 100644 index 0000000..1629f95 --- /dev/null +++ b/Dockerfile.edge @@ -0,0 +1,55 @@ +# Build iofog-nats wrapper and install nats-server for arm/v7 and riscv64 edge targets. +# golang:1.26.4-alpine — sha256:9169234cc43b396435c64e45538fe6d4ffa237e7f988b9ab32abdfa0c3141979 +FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine@sha256:9169234cc43b396435c64e45538fe6d4ffa237e7f988b9ab32abdfa0c3141979 AS go-builder +ARG TARGETOS +ARG TARGETARCH +ARG TARGETVARIANT + +WORKDIR /build +COPY . . + +ENV CGO_ENABLED=0 + +RUN if [ "${TARGETARCH}" = "arm" ] && [ "${TARGETVARIANT}" = "v7" ]; then export GOARM=7; fi && \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o iofog-nats ./cmd/iofog-nats + +RUN if [ "${TARGETARCH}" = "arm" ] && [ "${TARGETVARIANT}" = "v7" ]; then export GOARM=7; fi && \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install github.com/nats-io/nats-server/v2@v2.14.2 + +RUN mkdir -p /out && \ + find /go/bin -name "nats-server" -type f -exec cp {} /out/nats-server \; + +# alpine:3.22 — sha256:310c62b5e7ca5b08167e4384c68db0fd2905dd9c7493756d356e893909057601 +FROM alpine:3.22@sha256:310c62b5e7ca5b08167e4384c68db0fd2905dd9c7493756d356e893909057601 AS runtime-staging +RUN apk add --no-cache ca-certificates tzdata shadow && \ + adduser -D -u 10000 -h /home/runner runner && \ + mkdir -p /home/runner/run /home/runner/data /home/runner/bin /home/runner/nats/jwt && \ + chown -R runner:runner /home/runner + +COPY --from=go-builder /build/iofog-nats /home/runner/bin/iofog-nats +COPY --from=go-builder /out/nats-server /home/runner/bin/nats-server +COPY LICENSE /licenses/LICENSE + +RUN rm -rf /sbin/apk \ + /lib/apk \ + /etc/apk \ + /var/cache/apk + +FROM scratch + +ARG OCI_SOURCE_REPO +ARG OCI_VERSION +ARG OCI_REVISION +ARG NATS_DISTRIBUTION + +LABEL org.opencontainers.image.source="${OCI_SOURCE_REPO}" \ + org.opencontainers.image.version="${OCI_VERSION}" \ + org.opencontainers.image.revision="${OCI_REVISION}" \ + distribution="${NATS_DISTRIBUTION}" + +COPY --from=runtime-staging / / + +USER 10000 +WORKDIR /home/runner + +CMD ["/home/runner/bin/iofog-nats"] diff --git a/Makefile b/Makefile index dbc666f..a7ab420 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,21 @@ BINARY_PATH := bin/$(BINARY) LDFLAGS := -trimpath -ldflags="-s -w" IMAGE ?= iofog-nats:latest -.PHONY: all build test lint fmt fmt-check clean install docker-build +GOBIN ?= $(shell go env GOBIN) +ifeq ($(GOBIN),) +GOBIN := $(shell go env GOPATH)/bin +endif + +export PATH := $(GOBIN):$(PATH) + +# golangci-lint — pinned version; override with GOLANGCI_LINT_VERSION=vX.Y.Z +GOLANGCI_LINT_VERSION ?= v2.12.2 +GOLANGCI_LINT := $(GOBIN)/golangci-lint + +GOVULNCHECK_VERSION ?= v1.1.4 +GOSEC_SCOPE := ./cmd/... ./internal/... + +.PHONY: all build test lint lint-fix install-lint fmt fmt-check vet vulncheck security-code clean install docker-build all: build test @@ -17,8 +31,22 @@ build: test: go test ./... -lint: fmt-check - go vet ./... +$(GOLANGCI_LINT): + @echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION) -> $(GOBIN)..." + @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ + | sh -s -- -b $(GOBIN) $(GOLANGCI_LINT_VERSION) + @echo "golangci-lint $(GOLANGCI_LINT_VERSION) installed" + +install-lint: $(GOLANGCI_LINT) + @$(GOLANGCI_LINT) version + +lint: $(GOLANGCI_LINT) fmt-check + @echo "Running golangci-lint $(GOLANGCI_LINT_VERSION)..." + @$(GOLANGCI_LINT) run --config .golangci.yaml + +lint-fix: $(GOLANGCI_LINT) + @echo "Running golangci-lint $(GOLANGCI_LINT_VERSION) with --fix..." + @$(GOLANGCI_LINT) run --config .golangci.yaml --fix fmt: go fmt ./... @@ -26,6 +54,28 @@ fmt: fmt-check: @test -z "$$(gofmt -l .)" || (echo "Run 'make fmt' or 'gofmt -w .'"; exit 1) +vet: + go vet ./... + +vulncheck: + @echo "Running govulncheck..." + @if ! command -v govulncheck >/dev/null 2>&1; then \ + echo "Installing govulncheck..."; \ + go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION); \ + fi + @chmod +x scripts/vulncheck.sh + @scripts/vulncheck.sh + @echo "Verifying module integrity..." + @go mod verify + +security-code: + @echo "Running Go static security analysis..." + @if ! command -v gosec >/dev/null 2>&1; then \ + echo "Installing gosec..."; \ + go install github.com/securego/gosec/v2/cmd/gosec@latest; \ + fi + @gosec -exclude-dir=build $(GOSEC_SCOPE) + clean: rm -rf bin/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..30e3442 --- /dev/null +++ b/NOTICE @@ -0,0 +1,9 @@ +Notice + +Copyright 2023-2026 Datasance Teknoloji A.S. + +This program and the accompanying materials are made available under the +terms of the Eclipse Public License v. 2.0 which is available at +http://www.eclipse.org/legal/epl-2.0 + +SPDX-License-Identifier: EPL-2.0 diff --git a/README.md b/README.md index 4b8dfd6..cbb7698 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ # NATS Server for Eclipse ioFog -NATS server image for use on **Eclipse ioFog** (Kubernetes or edge with ioFog-agent). Config, account config, and SSL certs are provided via **volume mounts**; the wrapper starts [nats-server](https://github.com/nats-io/nats-server) v2.12.4 and watches for file changes, triggering a config reload (SIGHUP) without restart. +[![CI](https://github.com/Datasance/nats-server/actions/workflows/ci.yml/badge.svg)](https://github.com/Datasance/nats-server/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/Datasance/nats-server?include_prereleases)](https://github.com/Datasance/nats-server/releases) +[![Go](https://img.shields.io/badge/Go-1.26.4-blue.svg)](https://go.dev/) +[![License](https://img.shields.io/badge/License-EPL--2.0-blue.svg)](LICENSE) +[![govulncheck](https://github.com/Datasance/nats-server/actions/workflows/govulncheck.yml/badge.svg)](https://github.com/Datasance/nats-server/actions/workflows/govulncheck.yml) -Both **Kubernetes** (ioFog-controller mounting ConfigMaps/Secrets) and **ioFog edge** (ioFog-agent binding config) use the same contract: mount the server config, account config, and SSL directory at the paths below (or override with env vars). +**Upstream:** [eclipse-iofog/nats-server](https://github.com/eclipse-iofog/nats-server) · **Datasance distribution:** [Datasance/nats-server](https://github.com/Datasance/nats-server) + +NATS server image for use on **Eclipse ioFog** and **Datasance PoT** (Kubernetes or edge with Edgelet). Config, account config, and TLS certs are provided via **volume mounts**; the wrapper starts [nats-server](https://github.com/nats-io/nats-server) v2.14.2 and watches for file changes, triggering reload (SIGHUP) or restart (leaf mode, non-TLS changes) as appropriate. + +Both **Kubernetes** (ioFog-controller mounting ConfigMaps/Secrets) and **Edgelet** (Edgelet binding config) use the same contract: mount the server config, account config, and TLS directory at the paths below (or override with env vars). ## Environment variables @@ -10,10 +18,11 @@ Both **Kubernetes** (ioFog-controller mounting ConfigMaps/Secrets) and **ioFog e | ------------------- | ------------------------- | --------------------------------------------------------------------------- | | `NATS_CONF` | `/etc/nats/config/server.conf` | Server config file path (passed to nats-server as `-c`). | | `NATS_ACCOUNTS` | `/etc/nats/config/accounts.conf` | Account config file; watched for changes and triggers reload. | -| `NATS_SSL_DIR` | `/etc/nats/certs` | Directory for TLS material; watched for changes and triggers reload. | +| `NATS_TLS_DIR` | `/etc/nats/certs` | Directory for TLS material; watched for changes and triggers reload. | +| `NATS_SSL_DIR` | (deprecated fallback) | Deprecated; used only when `NATS_TLS_DIR` is unset. | | `NATS_JWT_DIR` | `/home/runner/nats/jwt` | Writable directory for JWT assets used by nats-server resolver (server config must point here). Synced from `NATS_JWT_MOUNT_DIR` at startup and on change. | | `NATS_JWT_MOUNT_DIR`| `/tmp/nats/jwt` | Read-only mount (e.g. K8s/ioFog) where account JWTs are placed. Watched for changes; contents are synced into `NATS_JWT_DIR` (copy and remove orphans) before reload. | -| `NATS_SERVER_MODE` | `server` | `server` (full reload on any change; reconcile + claims push on JWT) or `leaf` (reload only on SSL/TLS cert change; reconcile + claims push on JWT; full resolver). | +| `NATS_SERVER_MODE` | `server` | `server` (full reload on any change; reconcile + claims push on JWT) or `leaf` (SIGHUP only on TLS cert change; SIGINT+restart for other changes; reconcile + claims push on JWT; full resolver). | | `NATS_CREDS_DIR` | `/etc/nats/creds/` | Directory for creds files; watched for changes and triggers reload. | | `NATS_SERVER_BIN` | `/home/runner/bin/nats-server` | Path to the nats-server binary (override for local dev, e.g. `nats-server`). | | `NATS_MONITOR_PORT` | `8222` | HTTP monitoring port (nats-server `-m`). Set to `0` to disable. | @@ -25,22 +34,38 @@ The server config file may use **environment variable placeholders** (e.g. `$SER ## Volume mounts -- **Server config**: Mount the NATS server config file at `NATS_CONF`. It may `include` the account file and reference cert paths under `NATS_SSL_DIR`. +- **Server config**: Mount the NATS server config file at `NATS_CONF`. It may `include` the account file and reference cert paths under `NATS_TLS_DIR`. - **Account config**: Mount at `NATS_ACCOUNTS` (or include it from the server config via a relative path). -- **SSL certs**: Mount TLS material (e.g. `ca.crt`, `tls.crt`, `tls.key`) under `NATS_SSL_DIR` (or subdirs). Paths in the server config should match the mount location. +- **TLS certs**: Mount TLS material (e.g. `ca.crt`, `tls.crt`, `tls.key`) under `NATS_TLS_DIR` (or subdirs). Paths in the server config should match the mount location. ## Reload behaviour -The wrapper watches `NATS_CONF`, `NATS_ACCOUNTS` (if present), `NATS_SSL_DIR`, `NATS_JWT_MOUNT_DIR` (if present), and `NATS_CREDS_DIR` (directory watchers start only if paths exist). Before starting nats-server, and on each change to `NATS_JWT_MOUNT_DIR`, it syncs `*.jwt` files from the mount dir into `NATS_JWT_DIR` (copy and remove orphans so the JWT dir exactly mirrors the mount). It sends **SIGHUP** when appropriate: **server** mode on any change; **leaf** mode only when `NATS_SSL_DIR` (SSL/TLS certs) changes. When the cause is JWT, after reload the wrapper runs JetStream account reconciliation and pushes account JWTs via `$SYS.REQ.CLAIMS.UPDATE` for both server and leaf (leaf uses full resolver). +The wrapper watches `NATS_CONF`, `NATS_ACCOUNTS` (if present), `NATS_TLS_DIR`, `NATS_JWT_MOUNT_DIR` (if present), and `NATS_CREDS_DIR` (directory watchers start only if paths exist). Before starting nats-server, and on each change to `NATS_JWT_MOUNT_DIR`, it syncs `*.jwt` files from the mount dir into `NATS_JWT_DIR` (copy and remove orphans so the JWT dir exactly mirrors the mount). It sends **SIGHUP** when appropriate: **server** mode on any change; **leaf** mode only when `NATS_TLS_DIR` (TLS certs) changes. In **leaf** mode, config, accounts, or creds changes trigger **SIGINT** stop and restart so new settings load. When the cause is JWT, after reload the wrapper runs JetStream account reconciliation and pushes account JWTs via `$SYS.REQ.CLAIMS.UPDATE` for both server and leaf (leaf uses full resolver). ## JetStream account purge (reconcile on account removal) When an account is removed from the JWT resolver directory, NATS no longer accepts that account but JetStream may still hold its data. The wrapper reconciles accounts that have JetStream data on disk (subdirectories under the JetStream store directory) with the current resolver accounts (`NATS_JWT_DIR`). Any account that has a JetStream directory but is no longer in the resolver is purged via the JetStream Account Purge API (`$JS.API.ACCOUNT.PURGE.{account}`) using system account credentials. This runs once after startup (after a short delay) and again after each JWT directory change (after reload). No snapshot file is used; behaviour is consistent across reboots. Set `NATS_SYS_USER_CRED_PATH` (and optionally `NATS_JETSTREAM_STORE_DIR` or rely on parsing from server config) to enable purge; if unset, reconciliation still runs but purge API calls are skipped. -## Image +## Container image + +| Platform | Dockerfile | Base | Registry example | +|----------|------------|------|------------------| +| linux/amd64, linux/arm64 | `Dockerfile` | UBI 9 micro (Red Hat certified) | `ghcr.io/datasance/nats:2.14.2` | +| linux/arm/v7, linux/riscv64 | `Dockerfile.edge` | Alpine 3.22 | same manifest tag | + +- **User**: non-root `runner` (uid 10000). +- **Production binaries**: `iofog-nats` (entrypoint) and `nats-server` v2.14.2 only — **no nats-cli** in published images. +- **Release tags** (on `v*` git tag only): `:semver` (e.g. `:2.14.2`), `:latest`, and `:main` (same digest). -- **Base**: Red Hat UBI 9 micro, non-root user `runner` (uid 10000). -- **Binaries**: `iofog-nats` (entrypoint), nats-server v2.12.4, and **nats-cli** at `/home/runner/bin/nats` for debugging. +Use **`Dockerfile.dev`** locally when you need nats-cli for debugging; it is not published to GHCR. + +## CI + +| Workflow | Purpose | +|----------|---------| +| [`.github/workflows/ci.yml`](.github/workflows/ci.yml) | Lint, test, Docker build smoke (4 platforms, push disabled) | +| [`.github/workflows/govulncheck.yml`](.github/workflows/govulncheck.yml) | Weekly dependency vulnerability scan | +| [`.github/workflows/release.yml`](.github/workflows/release.yml) | Multi-arch GHCR publish on `v*` tag push | ## Build @@ -52,6 +77,10 @@ go build -o bin/iofog-nats ./cmd/iofog-nats Other targets: `make test`, `make lint`, `make clean`, `make docker-build` (image name via `IMAGE=...`). +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + ## License -See [LICENSE](LICENSE). +See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/cmd/iofog-nats/main.go b/cmd/iofog-nats/main.go index 6fd5d70..ad6d2f6 100644 --- a/cmd/iofog-nats/main.go +++ b/cmd/iofog-nats/main.go @@ -1,15 +1,3 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package main import ( @@ -38,7 +26,7 @@ const ( func main() { natsConf := config.GetNatsConf() natsAccounts := config.GetNatsAccounts() - natsSSLDir := config.GetNatsSSLDir() + natsTLSDir := config.GetNatsTLSDir() natsJWTDir := config.GetNatsJWTDir() natsJWTMountDir := config.GetNatsJWTMountDir() natsCredsDir := config.GetNatsCredsDir() @@ -147,9 +135,9 @@ func main() { log.Printf("JWT sync after change: copied=%d removed=%d", copied, removed) } } - // Leaf supports reload only for SSL/TLS cert changes; server supports full reload. - // For leaf with non-SSL changes (config, accounts, jwt, creds), SIGINT and restart so new config is loaded. - if config.GetNatsServerMode() != "leaf" || causes["ssl"] { + // Leaf supports reload only for TLS cert changes; server supports full reload. + // For leaf with non-TLS changes (config, accounts, jwt, creds), SIGINT and restart so new config is loaded. + if config.GetNatsServerMode() != "leaf" || causes["tls"] { if err := server.Reload(); err != nil { log.Printf("Reload after change: %v", err) } @@ -174,26 +162,26 @@ func main() { } // Watch server config file; on change trigger coalesced reload - go watch.WatchConfigFile(ctx, natsConf, debounce, func() { scheduleReload("config") }) + go watch.ConfigFile(ctx, natsConf, debounce, func() { scheduleReload("config") }) // Watch account config file if it exists if watch.FileExists(natsAccounts) { - go watch.WatchConfigFile(ctx, natsAccounts, debounce, func() { scheduleReload("accounts") }) + go watch.ConfigFile(ctx, natsAccounts, debounce, func() { scheduleReload("accounts") }) } - // Watch SSL directory if it exists - if info, err := os.Stat(natsSSLDir); err == nil && info.IsDir() { - go watch.WatchDir(ctx, natsSSLDir, debounce, func() { scheduleReload("ssl") }) + // Watch TLS directory if it exists + if info, err := os.Stat(natsTLSDir); err == nil && info.IsDir() { + go watch.Dir(ctx, natsTLSDir, debounce, func() { scheduleReload("tls") }) } // Watch JWT mount directory if it exists; on change sync to JWT dir, coalesced reload/restart, then reconcile and claims push if info, err := os.Stat(natsJWTMountDir); err == nil && info.IsDir() { - go watch.WatchDir(ctx, natsJWTMountDir, debounce, func() { scheduleReload("jwt") }) + go watch.Dir(ctx, natsJWTMountDir, debounce, func() { scheduleReload("jwt") }) } // Watch creds directory if it exists if info, err := os.Stat(natsCredsDir); err == nil && info.IsDir() { - go watch.WatchDir(ctx, natsCredsDir, debounce, func() { scheduleReload("creds") }) + go watch.Dir(ctx, natsCredsDir, debounce, func() { scheduleReload("creds") }) } for { @@ -205,7 +193,7 @@ func main() { } restartMu.Unlock() if r { - log.Printf("NATS server stopped for restart, starting again") + log.Print("NATS server stopped for restart, starting again") startServer() continue } @@ -219,7 +207,7 @@ func main() { // fileSHA256 returns the SHA256 hash of the file at path, or an error if the file cannot be read. func fileSHA256(path string) ([32]byte, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- config path from NATS_CONF contract if err != nil { return [32]byte{}, err } @@ -231,7 +219,7 @@ func fileSHA256(path string) ([32]byte, error) { func runJetStreamReconcile(serverConfPath, jwtDir string) { storeDir := config.GetJetStreamStoreDir(serverConfPath) if storeDir == "" { - log.Printf("ERROR: JetStream store dir not set or unreadable, skipping account purge reconciliation") + log.Print("ERROR: JetStream store dir not set or unreadable, skipping account purge reconciliation") return } accountsWithJS, err := jspurge.AccountsFromJetStreamStore(storeDir) @@ -250,7 +238,7 @@ func runJetStreamReconcile(serverConfPath, jwtDir string) { log.Printf("JetStream account reconciliation: store_dir=%s, resolver_accounts=%d, to_purge=%d", storeDir, len(currentResolver), len(toPurge)) if credsPath == "" { - log.Printf("NATS_SYS_USER_CRED_PATH unset, skipping purge API calls") + log.Print("NATS_SYS_USER_CRED_PATH unset, skipping purge API calls") return } ctx := context.Background() diff --git a/go.mod b/go.mod index 955f2b2..613a705 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/eclipse-iofog/nats-server -go 1.24 +go 1.26.4 require ( - github.com/fsnotify/fsnotify v1.7.0 - github.com/nats-io/nats.go v1.48.0 + github.com/fsnotify/fsnotify v1.10.1 + github.com/nats-io/nats.go v1.51.0 ) require ( - github.com/klauspost/compress v1.18.0 // indirect - github.com/nats-io/nkeys v0.4.11 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 888f024..6cc0e1a 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,14 @@ -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/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/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= -github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +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/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/claimspush/claimspush.go b/internal/claimspush/claimspush.go index dd7f4d7..f8f1afc 100644 --- a/internal/claimspush/claimspush.go +++ b/internal/claimspush/claimspush.go @@ -1,15 +1,3 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package claimspush import ( @@ -62,7 +50,7 @@ func PushAccountJWTs(ctx context.Context, jwtDir, clientURL, credsPath string, t var pushed, failed int for _, account := range accounts { jwtPath := filepath.Join(jwtDir, account+".jwt") - raw, err := os.ReadFile(jwtPath) + raw, err := os.ReadFile(jwtPath) // #nosec G304 -- JWT path derived from resolver dir listing if err != nil { log.Printf("Claims update: failed to read %s: %v", jwtPath, err) failed++ diff --git a/internal/config/env.go b/internal/config/env.go index 5daf0e1..544ee85 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -1,48 +1,39 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package config import ( "bufio" + "log" "os" "path/filepath" "strconv" "strings" + "sync" ) const ( - EnvNatsConf = "NATS_CONF" - EnvNatsAccounts = "NATS_ACCOUNTS" - EnvNatsSSLDir = "NATS_SSL_DIR" - EnvNatsJWTDir = "NATS_JWT_DIR" - EnvNatsJWTMountDir = "NATS_JWT_MOUNT_DIR" - EnvNatsServerMode = "NATS_SERVER_MODE" - EnvNatsCredsDir = "NATS_CREDS_DIR" - EnvNatsServerBin = "NATS_SERVER_BIN" - EnvNatsMonitorPort = "NATS_MONITOR_PORT" - EnvNatsSysUserCredPath = "NATS_SYS_USER_CRED_PATH" - EnvNatsClientURL = "NATS_CLIENT_URL" - EnvNatsJetStreamStoreDir = "NATS_JETSTREAM_STORE_DIR" - DefaultNatsConf = "/etc/nats/config/server.conf" - DefaultNatsAccounts = "/etc/nats/config/accounts.conf" - DefaultNatsSSLDir = "/etc/nats/certs" - DefaultNatsJWTDir = "/home/runner/nats/jwt" - DefaultNatsJWTMountDir = "/tmp/nats/jwt" - DefaultNatsServerMode = "server" - DefaultNatsCredsDir = "/etc/nats/creds/" - DefaultNatsServerBin = "/home/runner/bin/nats-server" - DefaultNatsMonitorPort = 8222 - DefaultNatsClientURL = "nats://127.0.0.1:4222" + EnvNatsConf = "NATS_CONF" + EnvNatsAccounts = "NATS_ACCOUNTS" + EnvNatsTLSDir = "NATS_TLS_DIR" + EnvNatsSSLDir = "NATS_SSL_DIR" + EnvNatsJWTDir = "NATS_JWT_DIR" + EnvNatsJWTMountDir = "NATS_JWT_MOUNT_DIR" + EnvNatsServerMode = "NATS_SERVER_MODE" + EnvNatsCredsDir = "NATS_CREDS_DIR" // #nosec G101 -- env var name, not a credential + EnvNatsServerBin = "NATS_SERVER_BIN" + EnvNatsMonitorPort = "NATS_MONITOR_PORT" + EnvNatsSysUserCredPath = "NATS_SYS_USER_CRED_PATH" // #nosec G101 -- env var name, not a credential + EnvNatsClientURL = "NATS_CLIENT_URL" + EnvNatsJetStreamStoreDir = "NATS_JETSTREAM_STORE_DIR" + DefaultNatsConf = "/etc/nats/config/server.conf" + DefaultNatsAccounts = "/etc/nats/config/accounts.conf" + DefaultNatsTLSDir = "/etc/nats/certs" + DefaultNatsJWTDir = "/home/runner/nats/jwt" + DefaultNatsJWTMountDir = "/tmp/nats/jwt" + DefaultNatsServerMode = "server" + DefaultNatsCredsDir = "/etc/nats/creds/" // #nosec G101 -- default mount path, not a credential + DefaultNatsServerBin = "/home/runner/bin/nats-server" + DefaultNatsMonitorPort = 8222 + DefaultNatsClientURL = "nats://127.0.0.1:4222" ) // GetNatsConf returns the server config file path from NATS_CONF, or DefaultNatsConf if unset. @@ -61,12 +52,21 @@ func GetNatsAccounts() string { return DefaultNatsAccounts } -// GetNatsSSLDir returns the SSL certs directory from NATS_SSL_DIR, or DefaultNatsSSLDir if unset. -func GetNatsSSLDir() string { +var natsSSLDirDeprecation sync.Once + +// GetNatsTLSDir returns the TLS certs directory from NATS_TLS_DIR, or DefaultNatsTLSDir if unset. +// If NATS_TLS_DIR is unset, NATS_SSL_DIR is accepted as a deprecated fallback. +func GetNatsTLSDir() string { + if p := os.Getenv(EnvNatsTLSDir); p != "" { + return p + } if p := os.Getenv(EnvNatsSSLDir); p != "" { + natsSSLDirDeprecation.Do(func() { + log.Printf("%s is deprecated; use %s instead", EnvNatsSSLDir, EnvNatsTLSDir) + }) return p } - return DefaultNatsSSLDir + return DefaultNatsTLSDir } // GetNatsJWTDir returns the JWT directory from NATS_JWT_DIR, or DefaultNatsJWTDir if unset. @@ -170,7 +170,7 @@ func GetJetStreamStoreDir(serverConfPath string) string { // parseJetStreamStoreDirFromConfig reads the server config file and extracts jetstream.store_dir value. // Returns empty string on any error or if not found. func parseJetStreamStoreDirFromConfig(path string) string { - f, err := os.Open(path) + f, err := os.Open(path) // #nosec G304 -- server config path from NATS_CONF contract if err != nil { return "" } diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 0000000..103fb7a --- /dev/null +++ b/internal/config/env_test.go @@ -0,0 +1,189 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func clearEnv(t *testing.T, keys ...string) { + t.Helper() + for _, k := range keys { + t.Setenv(k, "") + } +} + +func TestGetNatsConf_default(t *testing.T) { + clearEnv(t, EnvNatsConf) + if got := GetNatsConf(); got != DefaultNatsConf { + t.Fatalf("GetNatsConf() = %q, want %q", got, DefaultNatsConf) + } +} + +func TestGetNatsConf_override(t *testing.T) { + t.Setenv(EnvNatsConf, "/custom/server.conf") + if got := GetNatsConf(); got != "/custom/server.conf" { + t.Fatalf("GetNatsConf() = %q, want /custom/server.conf", got) + } +} + +func TestGetNatsTLSDir(t *testing.T) { + tests := []struct { + name string + tlsDir string + sslDir string + want string + }{ + { + name: "default when unset", + want: DefaultNatsTLSDir, + }, + { + name: "NATS_TLS_DIR takes precedence", + tlsDir: "/tls/primary", + sslDir: "/ssl/fallback", + want: "/tls/primary", + }, + { + name: "NATS_SSL_DIR deprecated fallback", + sslDir: "/ssl/legacy", + want: "/ssl/legacy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t, EnvNatsTLSDir, EnvNatsSSLDir) + if tt.tlsDir != "" { + t.Setenv(EnvNatsTLSDir, tt.tlsDir) + } + if tt.sslDir != "" { + t.Setenv(EnvNatsSSLDir, tt.sslDir) + } + if got := GetNatsTLSDir(); got != tt.want { + t.Fatalf("GetNatsTLSDir() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetNatsServerMode(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "default", want: DefaultNatsServerMode}, + {name: "trim and lower", env: " LEAF ", want: "leaf"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t, EnvNatsServerMode) + if tt.env != "" { + t.Setenv(EnvNatsServerMode, tt.env) + } + if got := GetNatsServerMode(); got != tt.want { + t.Fatalf("GetNatsServerMode() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetNatsMonitorPort(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {name: "default", want: DefaultNatsMonitorPort}, + {name: "valid override", env: "9090", want: 9090}, + {name: "zero disables", env: "0", want: 0}, + {name: "invalid falls back", env: "not-a-port", want: DefaultNatsMonitorPort}, + {name: "out of range falls back", env: "70000", want: DefaultNatsMonitorPort}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t, EnvNatsMonitorPort) + if tt.env != "" { + t.Setenv(EnvNatsMonitorPort, tt.env) + } + if got := GetNatsMonitorPort(); got != tt.want { + t.Fatalf("GetNatsMonitorPort() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestGetNatsSysUserCredPath(t *testing.T) { + clearEnv(t, EnvNatsSysUserCredPath, EnvNatsCredsDir) + if got := GetNatsSysUserCredPath(); got != "" { + t.Fatalf("GetNatsSysUserCredPath() = %q, want empty", got) + } + + t.Setenv(EnvNatsCredsDir, "/etc/nats/creds/") + t.Setenv(EnvNatsSysUserCredPath, "sys.creds") + if got := GetNatsSysUserCredPath(); got != filepath.Join("/etc/nats/creds/", "sys.creds") { + t.Fatalf("GetNatsSysUserCredPath() = %q, want joined relative path", got) + } + + t.Setenv(EnvNatsSysUserCredPath, "/abs/sys.creds") + if got := GetNatsSysUserCredPath(); got != "/abs/sys.creds" { + t.Fatalf("GetNatsSysUserCredPath() = %q, want absolute path", got) + } +} + +func TestGetJetStreamStoreDir(t *testing.T) { + dir := t.TempDir() + confPath := filepath.Join(dir, "server.conf") + + writeConf := func(content string) { + t.Helper() + if err := os.WriteFile(confPath, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + } + + t.Run("from env absolute", func(t *testing.T) { + clearEnv(t, EnvNatsJetStreamStoreDir) + t.Setenv(EnvNatsJetStreamStoreDir, "/data/jetstream") + if got := GetJetStreamStoreDir(confPath); got != "/data/jetstream" { + t.Fatalf("GetJetStreamStoreDir() = %q, want /data/jetstream", got) + } + }) + + t.Run("from env relative to config dir", func(t *testing.T) { + clearEnv(t, EnvNatsJetStreamStoreDir) + t.Setenv(EnvNatsJetStreamStoreDir, "js-store") + want := filepath.Join(dir, "js-store") + if got := GetJetStreamStoreDir(confPath); got != want { + t.Fatalf("GetJetStreamStoreDir() = %q, want %q", got, want) + } + }) + + t.Run("from config absolute store_dir", func(t *testing.T) { + clearEnv(t, EnvNatsJetStreamStoreDir) + writeConf("jetstream {\n store_dir: \"/var/lib/jetstream\"\n}\n") + if got := GetJetStreamStoreDir(confPath); got != "/var/lib/jetstream" { + t.Fatalf("GetJetStreamStoreDir() = %q, want /var/lib/jetstream", got) + } + }) + + t.Run("from config relative store_dir", func(t *testing.T) { + clearEnv(t, EnvNatsJetStreamStoreDir) + writeConf("jetstream {\n store_dir: \"data/js\"\n}\n") + want := filepath.Join(dir, "data/js") + if got := GetJetStreamStoreDir(confPath); got != want { + t.Fatalf("GetJetStreamStoreDir() = %q, want %q", got, want) + } + }) + + t.Run("missing config returns empty", func(t *testing.T) { + clearEnv(t, EnvNatsJetStreamStoreDir) + missing := filepath.Join(dir, "missing.conf") + if got := GetJetStreamStoreDir(missing); got != "" { + t.Fatalf("GetJetStreamStoreDir() = %q, want empty", got) + } + }) +} diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 4bbc4f7..de7a60f 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -1,20 +1,7 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package exec import ( "bufio" - "fmt" "log" "os" "os/exec" @@ -30,7 +17,7 @@ import ( func Start(name string, args []string, extraEnv []string, workDir string) (*exec.Cmd, error) { log.Printf("Starting command: %s with args: %v", name, args) - cmd := exec.Command(name, args...) + cmd := exec.Command(name, args...) // #nosec G204 -- wrapper exec of configured nats-server binary cmd.Env = append(os.Environ(), extraEnv...) if workDir != "" { cmd.Dir = workDir @@ -43,7 +30,7 @@ func Start(name string, args []string, extraEnv []string, workDir string) (*exec go func() { scanner := bufio.NewScanner(outReader) for scanner.Scan() { - fmt.Println(scanner.Text()) + log.Println(scanner.Text()) } }() @@ -54,7 +41,7 @@ func Start(name string, args []string, extraEnv []string, workDir string) (*exec go func() { scanner := bufio.NewScanner(errReader) for scanner.Scan() { - fmt.Println(scanner.Text()) + log.Println(scanner.Text()) } }() diff --git a/internal/jspurge/jspurge.go b/internal/jspurge/jspurge.go index 6434f06..996b77d 100644 --- a/internal/jspurge/jspurge.go +++ b/internal/jspurge/jspurge.go @@ -1,15 +1,3 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package jspurge import ( @@ -28,19 +16,19 @@ const ( purgeRequestTimeout = 10 * time.Second ) -// ApiResponse is the standard JetStream API response (type + optional error). -type ApiResponse struct { +// APIResponse is the standard JetStream API response (type + optional error). +type APIResponse struct { Type string `json:"type"` - Error *ApiError `json:"error,omitempty"` + Error *APIError `json:"error,omitempty"` } -// ApiError is the error field in ApiResponse. -type ApiError struct { +// APIError is the error field in APIResponse. +type APIError struct { Code int `json:"code"` Description string `json:"description,omitempty"` } -func (e *ApiError) Error() string { +func (e *APIError) Error() string { if e == nil { return "" } @@ -49,7 +37,7 @@ func (e *ApiError) Error() string { // JSApiAccountPurgeResponse is the response for account purge (includes initiated). type JSApiAccountPurgeResponse struct { - ApiResponse + APIResponse Initiated bool `json:"initiated,omitempty"` } @@ -140,9 +128,7 @@ func PurgeAccount(ctx context.Context, natsURL, credsPath, accountName string) e if resp.Error != nil { return resp.Error } - if !resp.Initiated { - // Server accepted but did not report initiated; treat as success for idempotency - } + _ = resp.Initiated // server may omit initiated; treat as success for idempotency return nil } diff --git a/internal/jspurge/jspurge_test.go b/internal/jspurge/jspurge_test.go new file mode 100644 index 0000000..035ca1b --- /dev/null +++ b/internal/jspurge/jspurge_test.go @@ -0,0 +1,109 @@ +package jspurge + +import ( + "os" + "path/filepath" + "testing" +) + +func writeJWT(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("jwt"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestToPurge(t *testing.T) { + tests := []struct { + name string + accountsWithJS []string + currentResolver []string + want []string + }{ + { + name: "none to purge when resolver matches", + accountsWithJS: []string{"A", "B"}, + currentResolver: []string{"A", "B"}, + want: nil, + }, + { + name: "purge accounts missing from resolver", + accountsWithJS: []string{"A", "B", "C"}, + currentResolver: []string{"A"}, + want: []string{"B", "C"}, + }, + { + name: "all purge when resolver empty", + accountsWithJS: []string{"A"}, + currentResolver: nil, + want: []string{"A"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToPurge(tt.accountsWithJS, tt.currentResolver) + if len(got) != len(tt.want) { + t.Fatalf("ToPurge() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("ToPurge() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestAccountsFromJWTDir(t *testing.T) { + dir := t.TempDir() + writeJWT(t, dir, "acct-one.jwt") + writeJWT(t, dir, "acct-two.jwt") + writeJWT(t, dir, "skip.jwt.delete") + writeJWT(t, dir, "not-jwt.txt") + + got, err := AccountsFromJWTDir(dir) + if err != nil { + t.Fatalf("AccountsFromJWTDir() err = %v", err) + } + want := []string{"acct-one", "acct-two"} + if len(got) != len(want) { + t.Fatalf("AccountsFromJWTDir() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("AccountsFromJWTDir() = %v, want %v", got, want) + } + } +} + +func TestAccountsFromJetStreamStore(t *testing.T) { + storeDir := t.TempDir() + jetstreamDir := filepath.Join(storeDir, "jetstream") + if err := os.MkdirAll(filepath.Join(jetstreamDir, "ACCT1"), 0o755); err != nil { + t.Fatalf("mkdir ACCT1: %v", err) + } + if err := os.MkdirAll(filepath.Join(jetstreamDir, "ACCT2"), 0o755); err != nil { + t.Fatalf("mkdir ACCT2: %v", err) + } + if err := os.WriteFile(filepath.Join(jetstreamDir, "file"), []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + got, err := AccountsFromJetStreamStore(storeDir) + if err != nil { + t.Fatalf("AccountsFromJetStreamStore() err = %v", err) + } + if len(got) != 2 { + t.Fatalf("AccountsFromJetStreamStore() = %v, want 2 accounts", got) + } + + missing, err := AccountsFromJetStreamStore(filepath.Join(storeDir, "missing")) + if err != nil { + t.Fatalf("AccountsFromJetStreamStore(missing) err = %v", err) + } + if missing != nil { + t.Fatalf("AccountsFromJetStreamStore(missing) = %v, want nil", missing) + } +} diff --git a/internal/jwtcopy/jwtcopy.go b/internal/jwtcopy/jwtcopy.go index debf912..353b3ef 100644 --- a/internal/jwtcopy/jwtcopy.go +++ b/internal/jwtcopy/jwtcopy.go @@ -1,15 +1,3 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package jwtcopy import ( @@ -39,7 +27,7 @@ func SyncMountToJWT(mountDir, jwtDir string) (copied int, removed int, err error if len(mountNames) == 0 { return 0, 0, nil } - if err := os.MkdirAll(jwtDir, 0755); err != nil { + if err := os.MkdirAll(jwtDir, 0755); err != nil { // #nosec G301 -- JWT dir must be traversable for nats-server resolver return 0, 0, err } for _, name := range mountNames { @@ -95,12 +83,12 @@ func listJWTFileNames(dir string) ([]string, error) { } func copyFile(src, dst string) error { - in, err := os.Open(src) + in, err := os.Open(src) // #nosec G304 -- src/dst under operator-controlled JWT mount paths if err != nil { return err } defer in.Close() - out, err := os.Create(dst) + out, err := os.Create(dst) // #nosec G304 -- src/dst under operator-controlled JWT mount paths if err != nil { return err } diff --git a/internal/jwtcopy/jwtcopy_test.go b/internal/jwtcopy/jwtcopy_test.go new file mode 100644 index 0000000..40e7bd1 --- /dev/null +++ b/internal/jwtcopy/jwtcopy_test.go @@ -0,0 +1,109 @@ +package jwtcopy + +import ( + "os" + "path/filepath" + "testing" +) + +func writeJWT(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestSyncMountToJWT_sameDirSkips(t *testing.T) { + dir := t.TempDir() + copied, removed, err := SyncMountToJWT(dir, dir) + if err != nil { + t.Fatalf("SyncMountToJWT() err = %v", err) + } + if copied != 0 || removed != 0 { + t.Fatalf("SyncMountToJWT() = copied %d removed %d, want 0, 0", copied, removed) + } +} + +func TestSyncMountToJWT_missingMountDir(t *testing.T) { + jwtDir := t.TempDir() + copied, removed, err := SyncMountToJWT(filepath.Join(jwtDir, "missing"), jwtDir) + if err != nil { + t.Fatalf("SyncMountToJWT() err = %v", err) + } + if copied != 0 || removed != 0 { + t.Fatalf("SyncMountToJWT() = copied %d removed %d, want 0, 0", copied, removed) + } +} + +func TestSyncMountToJWT_emptyMountSkipsRemoval(t *testing.T) { + mountDir := t.TempDir() + jwtDir := t.TempDir() + writeJWT(t, jwtDir, "orphan.jwt", "old") + + copied, removed, err := SyncMountToJWT(mountDir, jwtDir) + if err != nil { + t.Fatalf("SyncMountToJWT() err = %v", err) + } + if copied != 0 || removed != 0 { + t.Fatalf("SyncMountToJWT() = copied %d removed %d, want 0, 0", copied, removed) + } + if _, err := os.Stat(filepath.Join(jwtDir, "orphan.jwt")); err != nil { + t.Fatalf("orphan.jwt should remain when mount is empty: %v", err) + } +} + +func TestSyncMountToJWT_copyAndRemoveOrphans(t *testing.T) { + mountDir := t.TempDir() + jwtDir := t.TempDir() + + writeJWT(t, mountDir, "acct-a.jwt", "jwt-a") + writeJWT(t, mountDir, "acct-b.jwt", "jwt-b") + writeJWT(t, jwtDir, "acct-a.jwt", "stale-a") + writeJWT(t, jwtDir, "orphan.jwt", "remove-me") + writeJWT(t, jwtDir, "marked.jwt.delete", "skip") + + copied, removed, err := SyncMountToJWT(mountDir, jwtDir) + if err != nil { + t.Fatalf("SyncMountToJWT() err = %v", err) + } + if copied != 2 { + t.Fatalf("copied = %d, want 2", copied) + } + if removed != 1 { + t.Fatalf("removed = %d, want 1", removed) + } + + gotA, err := os.ReadFile(filepath.Join(jwtDir, "acct-a.jwt")) + if err != nil { + t.Fatalf("read acct-a.jwt: %v", err) + } + if string(gotA) != "jwt-a" { + t.Fatalf("acct-a.jwt = %q, want jwt-a", string(gotA)) + } + + if _, err := os.Stat(filepath.Join(jwtDir, "orphan.jwt")); !os.IsNotExist(err) { + t.Fatalf("orphan.jwt should be removed, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(jwtDir, "marked.jwt.delete")); err != nil { + t.Fatalf("marked.jwt.delete should remain: %v", err) + } +} + +func TestListJWTFileNames(t *testing.T) { + dir := t.TempDir() + writeJWT(t, dir, "one.jwt", "1") + writeJWT(t, dir, "two.jwt", "2") + writeJWT(t, dir, "skip.jwt.delete", "x") + if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + names, err := listJWTFileNames(dir) + if err != nil { + t.Fatalf("listJWTFileNames() err = %v", err) + } + if len(names) != 2 { + t.Fatalf("listJWTFileNames() = %v, want 2 entries", names) + } +} diff --git a/internal/nats/nats.go b/internal/nats/nats.go index 291c3e4..e523834 100644 --- a/internal/nats/nats.go +++ b/internal/nats/nats.go @@ -1,18 +1,7 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package nats import ( + "errors" "fmt" "log" "os/exec" @@ -39,7 +28,7 @@ func (s *Server) Start(serverConfPath string, exitCh chan<- error) error { defer s.mu.Unlock() if s.cmd != nil { - return fmt.Errorf("nats-server already started") + return errors.New("nats-server already started") } bin := config.GetNatsServerBin() @@ -76,12 +65,12 @@ func (s *Server) Reload() error { s.mu.Unlock() if cmd == nil || cmd.Process == nil { - return fmt.Errorf("nats-server not running") + return errors.New("nats-server not running") } if err := cmd.Process.Signal(syscall.SIGHUP); err != nil { return fmt.Errorf("failed to send SIGHUP: %w", err) } - log.Printf("Sent SIGHUP to nats-server for config reload") + log.Print("Sent SIGHUP to nats-server for config reload") return nil } @@ -93,11 +82,11 @@ func (s *Server) Stop() error { s.mu.Unlock() if cmd == nil || cmd.Process == nil { - return fmt.Errorf("nats-server not running") + return errors.New("nats-server not running") } if err := cmd.Process.Signal(syscall.SIGINT); err != nil { return fmt.Errorf("failed to send SIGINT: %w", err) } - log.Printf("Sent SIGINT to nats-server for graceful stop (restart)") + log.Print("Sent SIGINT to nats-server for graceful stop (restart)") return nil } diff --git a/internal/watch/config.go b/internal/watch/config.go index bdd9f5b..62ee926 100644 --- a/internal/watch/config.go +++ b/internal/watch/config.go @@ -1,15 +1,3 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package watch import ( @@ -25,10 +13,10 @@ import ( const defaultDebounce = 500 * time.Millisecond -// WatchConfigFile watches the config file at configPath for changes. On write/create -// (after debounce), it calls onReload. Runs until ctx is cancelled. +// ConfigFile watches the config file at configPath for changes. On write/create +// (after debounce), it calls onReload. Runs until ctx is canceled. // The parent directory of configPath must exist (e.g. volume-mounted). -func WatchConfigFile(ctx context.Context, configPath string, debounce time.Duration, onReload func()) { +func ConfigFile(ctx context.Context, configPath string, debounce time.Duration, onReload func()) { if debounce <= 0 { debounce = defaultDebounce } diff --git a/internal/watch/dir.go b/internal/watch/dir.go index 6eda66e..529b1b0 100644 --- a/internal/watch/dir.go +++ b/internal/watch/dir.go @@ -1,15 +1,3 @@ -/* - * ******************************************************************************* - * * Copyright (c) 2023 Datasance Teknoloji A.S. - * * - * * This program and the accompanying materials are made available under the - * * terms of the Eclipse Public License v. 2.0 which is available at - * * http://www.eclipse.org/legal/epl-2.0 - * * - * * SPDX-License-Identifier: EPL-2.0 - * ******************************************************************************* - */ - package watch import ( @@ -24,10 +12,10 @@ import ( "github.com/fsnotify/fsnotify" ) -// WatchDir watches basePath (and immediate subdirs) for changes. On any create/write/remove -// (after debounce), it calls onReload. Runs until ctx is cancelled. +// Dir watches basePath (and immediate subdirs) for changes. On any create/write/remove +// (after debounce), it calls onReload. Runs until ctx is canceled. // If basePath does not exist, the watcher returns without error (caller may start it when dir appears). -func WatchDir(ctx context.Context, basePath string, debounce time.Duration, onReload func()) { +func Dir(ctx context.Context, basePath string, debounce time.Duration, onReload func()) { if debounce <= 0 { debounce = defaultDebounce } diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go new file mode 100644 index 0000000..1605a02 --- /dev/null +++ b/internal/watch/watch_test.go @@ -0,0 +1,66 @@ +package watch + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestFileExists(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "config.conf") + if FileExists(filePath) { + t.Fatal("FileExists() = true for missing file") + } + if err := os.WriteFile(filePath, []byte("conf"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + if !FileExists(filePath) { + t.Fatal("FileExists() = false for existing file") + } + if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil { + t.Fatalf("mkdir subdir: %v", err) + } + if FileExists(filepath.Join(dir, "subdir")) { + t.Fatal("FileExists() = true for directory") + } +} + +func TestDir_missingBasePathReturns(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + Dir(ctx, filepath.Join(t.TempDir(), "missing"), 10*time.Millisecond, func() {}) + close(done) + }() + + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Dir did not return after cancel") + } +} + +func TestConfigFile_missingParentReturns(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + configPath := filepath.Join(t.TempDir(), "missing", "server.conf") + go func() { + ConfigFile(ctx, configPath, 10*time.Millisecond, func() {}) + close(done) + }() + + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("ConfigFile did not return after cancel") + } +} diff --git a/scripts/vulncheck.sh b/scripts/vulncheck.sh new file mode 100755 index 0000000..406b871 --- /dev/null +++ b/scripts/vulncheck.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Run govulncheck on nats-server module code paths. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +govulncheck -format=text ./cmd/... ./internal/...