From ba7471e0c6cc39341692863f34d8b04f15cce2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 18:27:42 +0300 Subject: [PATCH 1/8] Add NOTICE, bump Go to 1.26.4 and nats-server to v2.14.2, and rename NATS_SSL_DIR to NATS_TLS_DIR. Centralize licensing in NOTICE, pin the toolchain and embedded nats-server, and align TLS env naming with Controller while keeping NATS_SSL_DIR as a deprecated fallback. --- .dockerignore | 4 +++- .gitignore | 4 +++- Dockerfile | 4 ++-- Dockerfile-dev => Dockerfile.dev | 4 ++-- NOTICE | 9 +++++++++ README.md | 19 +++++++++--------- cmd/iofog-nats/main.go | 26 +++++++------------------ go.mod | 2 +- internal/claimspush/claimspush.go | 12 ------------ internal/config/env.go | 32 +++++++++++++++---------------- internal/exec/exec.go | 12 ------------ internal/jspurge/jspurge.go | 12 ------------ internal/jwtcopy/jwtcopy.go | 12 ------------ internal/nats/nats.go | 12 ------------ internal/watch/config.go | 12 ------------ internal/watch/dir.go | 12 ------------ 16 files changed, 53 insertions(+), 135 deletions(-) rename Dockerfile-dev => Dockerfile.dev (97%) create mode 100644 NOTICE 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/.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/Dockerfile b/Dockerfile index 99ac931..d975282 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build iofog-nats wrapper and install nats-server -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 mkdir -p /out && \ 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/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..f7e04db 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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. +NATS server image for use on **Eclipse ioFog** (Kubernetes or edge with ioFog-agent). 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 a config reload (SIGHUP) without restart. -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). +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 TLS directory at the paths below (or override with env vars). ## Environment variables @@ -10,10 +10,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` (reload only on TLS cert change; 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,13 +26,13 @@ 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. 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) @@ -40,7 +41,7 @@ When an account is removed from the JWT resolver directory, NATS no longer accep ## Image - **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. +- **Binaries**: `iofog-nats` (entrypoint), nats-server v2.14.2, and **nats-cli** at `/home/runner/bin/nats` for debugging. ## Build @@ -54,4 +55,4 @@ Other targets: `make test`, `make lint`, `make clean`, `make docker-build` (imag ## 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..bb9d2bf 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) } @@ -181,9 +169,9 @@ func main() { go watch.WatchConfigFile(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.WatchDir(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 diff --git a/go.mod b/go.mod index 955f2b2..021fa45 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/eclipse-iofog/nats-server -go 1.24 +go 1.26.4 require ( github.com/fsnotify/fsnotify v1.7.0 diff --git a/internal/claimspush/claimspush.go b/internal/claimspush/claimspush.go index dd7f4d7..7f4f877 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 ( diff --git a/internal/config/env.go b/internal/config/env.go index 5daf0e1..71cbebd 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -1,28 +1,19 @@ -/* - * ******************************************************************************* - * * 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" + EnvNatsTLSDir = "NATS_TLS_DIR" EnvNatsSSLDir = "NATS_SSL_DIR" EnvNatsJWTDir = "NATS_JWT_DIR" EnvNatsJWTMountDir = "NATS_JWT_MOUNT_DIR" @@ -35,7 +26,7 @@ const ( EnvNatsJetStreamStoreDir = "NATS_JETSTREAM_STORE_DIR" DefaultNatsConf = "/etc/nats/config/server.conf" DefaultNatsAccounts = "/etc/nats/config/accounts.conf" - DefaultNatsSSLDir = "/etc/nats/certs" + DefaultNatsTLSDir = "/etc/nats/certs" DefaultNatsJWTDir = "/home/runner/nats/jwt" DefaultNatsJWTMountDir = "/tmp/nats/jwt" DefaultNatsServerMode = "server" @@ -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. diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 4bbc4f7..063156c 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.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 exec import ( diff --git a/internal/jspurge/jspurge.go b/internal/jspurge/jspurge.go index 6434f06..565e987 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 ( diff --git a/internal/jwtcopy/jwtcopy.go b/internal/jwtcopy/jwtcopy.go index debf912..2ca6f9f 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 ( diff --git a/internal/nats/nats.go b/internal/nats/nats.go index 291c3e4..9fb2067 100644 --- a/internal/nats/nats.go +++ b/internal/nats/nats.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 nats import ( diff --git a/internal/watch/config.go b/internal/watch/config.go index bdd9f5b..1447310 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 ( diff --git a/internal/watch/dir.go b/internal/watch/dir.go index 6eda66e..c62be8c 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 ( From f37e427ef75d80f9d84178eb2aa75f5a57acced0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 18:33:46 +0300 Subject: [PATCH 2/8] Add golangci-lint, Makefile quality targets, and internal unit tests. Introduce edgelet-style lint and security tooling plus table-driven tests for config, jwtcopy, jspurge, and watch; fix lint findings and rename watch exports to satisfy revive. --- .golangci.yaml | 79 +++++++++++++ Makefile | 56 ++++++++- cmd/iofog-nats/main.go | 18 +-- internal/claimspush/claimspush.go | 2 +- internal/config/env.go | 48 ++++---- internal/config/env_test.go | 189 ++++++++++++++++++++++++++++++ internal/exec/exec.go | 7 +- internal/jspurge/jspurge.go | 18 ++- internal/jspurge/jspurge_test.go | 109 +++++++++++++++++ internal/jwtcopy/jwtcopy.go | 6 +- internal/jwtcopy/jwtcopy_test.go | 109 +++++++++++++++++ internal/nats/nats.go | 11 +- internal/watch/config.go | 6 +- internal/watch/dir.go | 6 +- internal/watch/watch_test.go | 66 +++++++++++ scripts/vulncheck.sh | 8 ++ 16 files changed, 673 insertions(+), 65 deletions(-) create mode 100644 .golangci.yaml create mode 100644 internal/config/env_test.go create mode 100644 internal/jspurge/jspurge_test.go create mode 100644 internal/jwtcopy/jwtcopy_test.go create mode 100644 internal/watch/watch_test.go create mode 100755 scripts/vulncheck.sh 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/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/cmd/iofog-nats/main.go b/cmd/iofog-nats/main.go index bb9d2bf..ad6d2f6 100644 --- a/cmd/iofog-nats/main.go +++ b/cmd/iofog-nats/main.go @@ -162,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 TLS directory if it exists if info, err := os.Stat(natsTLSDir); err == nil && info.IsDir() { - go watch.WatchDir(ctx, natsTLSDir, debounce, func() { scheduleReload("tls") }) + 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 { @@ -193,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 } @@ -207,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 } @@ -219,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) @@ -238,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/internal/claimspush/claimspush.go b/internal/claimspush/claimspush.go index 7f4f877..f8f1afc 100644 --- a/internal/claimspush/claimspush.go +++ b/internal/claimspush/claimspush.go @@ -50,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 71cbebd..544ee85 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -11,29 +11,29 @@ import ( ) const ( - 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" - 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" - DefaultNatsTLSDir = "/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. @@ -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 063156c..de7a60f 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -2,7 +2,6 @@ package exec import ( "bufio" - "fmt" "log" "os" "os/exec" @@ -18,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 @@ -31,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()) } }() @@ -42,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 565e987..996b77d 100644 --- a/internal/jspurge/jspurge.go +++ b/internal/jspurge/jspurge.go @@ -16,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 "" } @@ -37,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"` } @@ -128,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 2ca6f9f..353b3ef 100644 --- a/internal/jwtcopy/jwtcopy.go +++ b/internal/jwtcopy/jwtcopy.go @@ -27,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 { @@ -83,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 9fb2067..e523834 100644 --- a/internal/nats/nats.go +++ b/internal/nats/nats.go @@ -1,6 +1,7 @@ package nats import ( + "errors" "fmt" "log" "os/exec" @@ -27,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() @@ -64,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 } @@ -81,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 1447310..62ee926 100644 --- a/internal/watch/config.go +++ b/internal/watch/config.go @@ -13,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 c62be8c..529b1b0 100644 --- a/internal/watch/dir.go +++ b/internal/watch/dir.go @@ -12,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/... From 303612de9916cea77a1d6868983cec07ae70d505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 18:37:46 +0300 Subject: [PATCH 3/8] Replace legacy push workflow with modern CI and govulncheck. Add set-build-env for mirror-aware registry vars, lint/test/docker smoke on develop PRs without GHCR push, and weekly vulnerability scanning. --- .github/actions/set-build-env/action.yml | 49 ++++++++++ .github/workflows/ci.yml | 118 +++++++++++++++++++++++ .github/workflows/govulncheck.yml | 28 ++++++ .github/workflows/push.yaml | 86 ----------------- 4 files changed, 195 insertions(+), 86 deletions(-) create mode 100644 .github/actions/set-build-env/action.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/govulncheck.yml delete mode 100644 .github/workflows/push.yaml 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..4de27ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +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: Check for Dockerfile.edge + id: edge + run: | + if [ -f Dockerfile.edge ]; then + echo "exists=true" >> "${GITHUB_OUTPUT}" + else + echo "exists=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Build edge image (arm/v7, riscv64) + if: steps.edge.outputs.exists == 'true' + 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 - From f25686767841839fb1a364eb440d6e8e53760ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 18:57:25 +0300 Subject: [PATCH 4/8] Add tag-triggered release workflow and slim UBI production image. Remove curl, grep, and copied shared libraries from the UBI micro runtime; build static binaries with CGO_ENABLED=0 and digest-pin base images. Add release.yml to run quality gates then push amd64/arm64 to GHCR on v* tags with semver, latest, and main labels. --- .github/workflows/release.yml | 97 +++++++++++++++++++++++++++++++++++ Dockerfile | 47 ++++++++--------- 2 files changed, 117 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b39ce0a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +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: + name: Publish UBI image + runs-on: ubuntu-latest + needs: [quality] + permissions: + 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 }} + + - 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 }} + ${{ env.NATS_CONTAINER_IMAGE }}:latest + ${{ env.NATS_CONTAINER_IMAGE }}:main diff --git a/Dockerfile b/Dockerfile index d975282..72988c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ # Build iofog-nats wrapper and install nats-server -FROM --platform=$BUILDPLATFORM golang:1.26.4-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.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"] From c72f49fb222e53890a6b80032ffe2cfc449b2d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 20:39:38 +0300 Subject: [PATCH 5/8] Add scratch-based edge image for arm/v7 and riscv64. Build static iofog-nats and nats-server on Alpine staging, strip apk, and copy the runtime filesystem into a scratch final stage with uid 10000. --- Dockerfile.edge | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Dockerfile.edge 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"] From 7fcdbeb79fc3f3af1844dd290d0cc59a08657f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 20:39:42 +0300 Subject: [PATCH 6/8] Publish four-platform release manifest and build edge image in CI. Push UBI amd64/arm64 and Alpine edge arm/riscv64 as staging tags, merge with imagetools into semver/latest/main, and smoke-build both Dockerfiles on develop. --- .github/workflows/ci.yml | 10 ---- .github/workflows/release.yml | 109 ++++++++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4de27ce..b6087bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,17 +96,7 @@ jobs: cache-to: type=gha,mode=max tags: ${{ env.NATS_CONTAINER_IMAGE }}:ci - - name: Check for Dockerfile.edge - id: edge - run: | - if [ -f Dockerfile.edge ]; then - echo "exists=true" >> "${GITHUB_OUTPUT}" - else - echo "exists=false" >> "${GITHUB_OUTPUT}" - fi - - name: Build edge image (arm/v7, riscv64) - if: steps.edge.outputs.exists == 'true' uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b39ce0a..69014c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,11 +40,12 @@ jobs: - name: Check formatting run: make fmt-check - publish: - name: Publish UBI image + 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 @@ -77,6 +78,7 @@ jobs: 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: @@ -91,7 +93,102 @@ jobs: OCI_VERSION=${{ env.SEMVER }} OCI_REVISION=${{ github.sha }} NATS_DISTRIBUTION=${{ env.NATS_DISTRIBUTION }} - tags: | - ${{ env.NATS_CONTAINER_IMAGE }}:${{ env.SEMVER }} - ${{ env.NATS_CONTAINER_IMAGE }}:latest - ${{ env.NATS_CONTAINER_IMAGE }}:main + 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: ./.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" From 246e4454474c4ea7ed9627e4e0d8f82b27602975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 20:39:48 +0300 Subject: [PATCH 7/8] Bump fsnotify to v1.10.1 and nats.go to v1.51.0. Refresh indirect crypto, sys, nkeys, and compress versions via go mod tidy. --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 021fa45..613a705 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module github.com/eclipse-iofog/nats-server 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= From f4e02bc16173d88732529a0f2a872b2a856d775d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Mon, 15 Jun 2026 21:06:11 +0300 Subject: [PATCH 8/8] Document public README, CHANGELOG, and dual-mirror CONTRIBUTING. Align operator docs with v2.14.2 and multi-arch images; fix release manifest job checkout so set-build-env is available during tag publishes. --- .github/workflows/release.yml | 2 + CHANGELOG.md | 41 ++++++++++++++ CONTRIBUTING.md | 102 ++++++++++++++++++++++++++++++++++ README.md | 42 +++++++++++--- 4 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69014c8..5c003e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,6 +158,8 @@ jobs: contents: read packages: write steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/set-build-env env: IMAGE_REGISTRY: ${{ vars.IMAGE_REGISTRY }} 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/README.md b/README.md index f7e04db..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 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 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 TLS 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 @@ -14,7 +22,7 @@ Both **Kubernetes** (ioFog-controller mounting ConfigMaps/Secrets) and **ioFog e | `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 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. | @@ -32,16 +40,32 @@ The server config file may use **environment variable placeholders** (e.g. `$SER ## Reload behaviour -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. 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.14.2, 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 @@ -53,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) and [NOTICE](NOTICE).