diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e0fbf4..ae21917 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,16 +6,33 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: release: name: Publish GitHub Release runs-on: ubuntu-latest + permissions: + contents: write # create the release and upload assets + id-token: write # OIDC token for Sigstore keyless signing + attestations: write # write the SLSA provenance attestation steps: - uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Build source archive + id: archive + run: | + archive="runtimevar-consul-${GITHUB_REF_NAME}.tar.gz" + git archive --format=tar.gz \ + --prefix="runtimevar-consul-${GITHUB_REF_NAME}/" \ + -o "${archive}" "${GITHUB_REF_NAME}" + sha256sum "${archive}" > SHA256SUMS + echo "archive=${archive}" >> "${GITHUB_OUTPUT}" + - name: Generate SLSA provenance attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: ${{ steps.archive.outputs.archive }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -24,5 +41,8 @@ jobs: generate_release_notes: true draft: false prerelease: false + files: | + ${{ steps.archive.outputs.archive }} + SHA256SUMS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 47be3f9..d23af4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## Project Overview -- **Main Technology:** Go (>= 1.26) +- **Main Technology:** Go (>= 1.25) - **Primary Dependency:** [`github.com/hashicorp/consul/api`](https://github.com/hashicorp/consul) and [`gocloud.dev/runtimevar`](https://gocloud.dev). - **Architecture:** - `consulvar/`: Public API and `runtimevar` URL opener registration. @@ -42,7 +42,7 @@ The project uses `task` (Taskfile) as the task runner. - **Dependency Management:** - Maintain `go.mod` and `go.sum` via `task tidy`. - Always run `go mod vendor` after updating dependencies. - - Avoid raising the minimum Go version floor (currently 1.26) unless necessary. + - Avoid raising the minimum Go version floor (currently 1.25) unless necessary. - **Tooling:** - `gotestsum` for test execution and reporting. - `golangci-lint` for static analysis. diff --git a/README.md b/README.md index 4e8cdae..d9ceffb 100644 --- a/README.md +++ b/README.md @@ -46,20 +46,19 @@ Install the toolchain: ```bash go install gotest.tools/gotestsum@latest -# See https://golangci-lint.run/welcome/install/#local-installation -curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.5 +# golangci-lint v2 — see https://golangci-lint.run/welcome/install/#local-installation +curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.12.2 go install github.com/vektra/mockery/v2@latest # only needed to regenerate mocks -``` -# task: https://taskfile.dev/installation/ +# task: https://taskfile.dev/installation/ # git-cliff: https://git-cliff.org/docs/installation ``` -Requires Go 1.26 or newer (set by the `go` directive in `go.mod`; the dependencies pull the floor up to it). +Requires Go 1.25 or newer (the `go` directive in `go.mod`). Common commands (the `Makefile` delegates to `task`): ```bash -task ci # full pre-push gate: tidy + lint + build + tests on the minimum Go +task ci # full pre-push gate: tidy:check + lint + build + unit + integration task test # unit tests task test:integration # unit + integration (requires Docker) task lint @@ -67,7 +66,7 @@ task format task changelog:unreleased ``` -Run `task ci` before pushing. GitHub Actions runs on `stable` Go only, so `task ci` is the only place the version floor is enforced: it pins the toolchain to the minimum supported Go, so a dependency that raises the floor fails here rather than slipping through CI unnoticed. +Run `task ci` before pushing — it mirrors the GitHub Actions checks. Both CI and `task ci` run on whatever Go toolchain is installed; the minimum supported version is recorded by the `go` directive in `go.mod` and is not otherwise enforced. ## Commit messages diff --git a/SECURITY.md b/SECURITY.md index 11958e6..1997189 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,3 +5,20 @@ If you discover a security vulnerability within this project, please send an e-mail to **tiago.peczenyj+github@gmail.com**. All security vulnerabilities will be promptly addressed. We request that you do not report security-related issues through public GitHub issues. + +## Verifying release provenance + +Each tagged release publishes a source archive (`runtimevar-consul-vX.Y.Z.tar.gz`) +and a `SHA256SUMS` file on the [Releases](https://github.com/peczenyj/runtimevar-consul/releases) +page. The archive ships with [SLSA](https://slsa.dev) build provenance, signed +keylessly via [Sigstore](https://www.sigstore.dev/), generated by the release +workflow on GitHub Actions. + +To verify a downloaded archive with the [GitHub CLI](https://cli.github.com/): + +```bash +gh attestation verify runtimevar-consul-vX.Y.Z.tar.gz --repo peczenyj/runtimevar-consul +``` + +A successful check confirms the archive was produced by this repository's +release workflow for the given tag, and has not been tampered with since. diff --git a/consulvar/allowstale_test.go b/consulvar/allowstale_test.go new file mode 100644 index 0000000..1f8fb60 --- /dev/null +++ b/consulvar/allowstale_test.go @@ -0,0 +1,64 @@ +package consulvar_test + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/hashicorp/consul/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gocloud.dev/runtimevar" + + "github.com/peczenyj/runtimevar-consul/consulvar" +) + +// staleRecordingServer serves a single KV pair and records whether any request +// carried the Consul "stale" query parameter. +func staleRecordingServer(t *testing.T) (client *api.Client, sawStale *atomic.Bool) { + t.Helper() + sawStale = &atomic.Bool{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("stale") { + sawStale.Store(true) + } + w.Header().Set("X-Consul-Index", "1") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"Key":"k","CreateIndex":1,"ModifyIndex":1,"Value":"`+ + base64.StdEncoding.EncodeToString([]byte("v"))+`"}]`) + })) + t.Cleanup(srv.Close) + + cfg := api.DefaultConfig() + cfg.Address = strings.TrimPrefix(srv.URL, "http://") + c, err := api.NewClient(cfg) + require.NoError(t, err) + return c, sawStale +} + +// https://github.com/peczenyj/runtimevar-consul/issues/26 +// AllowStale set on Options must reach the Consul query as ?stale. +func TestOpenVariable_ForwardsAllowStale_Issue26(t *testing.T) { + client, sawStale := staleRecordingServer(t) + + v, err := consulvar.OpenVariable(client, "k", &consulvar.Options{ + Decoder: runtimevar.StringDecoder, + AllowStale: true, + }) + require.NoError(t, err) + defer v.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + snap, err := v.Watch(ctx) + require.NoError(t, err) + assert.Equal(t, "v", snap.Value.(string)) + assert.True(t, sawStale.Load(), "Options.AllowStale must reach the Consul query as ?stale") +} diff --git a/consulvar/consulvar.go b/consulvar/consulvar.go index aebc3da..630c711 100644 --- a/consulvar/consulvar.go +++ b/consulvar/consulvar.go @@ -85,6 +85,7 @@ func OpenVariable(client *api.Client, key string, opts *Options) (*runtimevar.Va Decoder: decoder, Datacenter: opts.Datacenter, Namespace: opts.Namespace, + AllowStale: opts.AllowStale, WaitTime: opts.WaitTime, }) return runtimevar.New(w), nil diff --git a/consulvar/internal/driver/watch_internal_test.go b/consulvar/internal/driver/watch_internal_test.go index 1296818..758f623 100644 --- a/consulvar/internal/driver/watch_internal_test.go +++ b/consulvar/internal/driver/watch_internal_test.go @@ -317,6 +317,40 @@ func TestWatchVariable_Mocked_TransportError(t *testing.T) { assert.Equal(t, time.Second, wait) } +// https://github.com/peczenyj/runtimevar-consul/issues/27 +// A successful poll must reset the consecutive-failure counter, so a later +// transport error restarts the backoff at 1s rather than escalating. +func TestWatchVariable_BackoffResetsAfterSuccessfulPoll_Issue27(t *testing.T) { + mk := consulmock.NewConsulKV(t) + mc := consulmock.NewConsulClient(t) + w := &Watcher{ + client: mc, + key: "k", + decoder: runtimevar.StringDecoder, + } + + mc.EXPECT().KV().Return(mk) + + boom := errors.New("network failure") + // Call A: transport error -> failures becomes 1, backoff 1s. + mk.EXPECT().Get("k", mock.Anything).Return(nil, nil, boom).Once() + // Call B, first poll: a successful but unchanged read (ModifyIndex == prev). + mk.EXPECT().Get("k", mock.Anything).Return( + &api.KVPair{Key: "k", Value: []byte("v"), ModifyIndex: 5}, + &api.QueryMeta{LastIndex: 5}, + nil, + ).Once() + // Call B, second poll: transport error again. + mk.EXPECT().Get("k", mock.Anything).Return(nil, nil, boom).Once() + + _, waitA := w.WatchVariable(context.Background(), nil) + require.Equal(t, time.Second, waitA) + + _, waitB := w.WatchVariable(context.Background(), &state{modifyIndex: 5}) + assert.Equal(t, time.Second, waitB, + "a successful poll must reset backoff; expected 1s, the schedule must not escalate to 2s") +} + func TestWatchVariable_Mocked_IndexReset(t *testing.T) { mk := consulmock.NewConsulKV(t) mc := consulmock.NewConsulClient(t) diff --git a/consulvar/internal/driver/watcher.go b/consulvar/internal/driver/watcher.go index fbe43a3..55dec3d 100644 --- a/consulvar/internal/driver/watcher.go +++ b/consulvar/internal/driver/watcher.go @@ -33,6 +33,9 @@ func (w *Watcher) WatchVariable(ctx context.Context, prev driver.State) (driver. w.failures++ return &state{err: err, updated: now}, backoff(w.failures) } + // A successful round-trip clears the backoff schedule, including on the + // poll-again paths below that loop without returning a new state. + w.failures = 0 // Index-reset safety: per Consul docs, if meta.LastIndex < waitIndex the // cluster's index has reset and the comparison is invalid. @@ -53,7 +56,6 @@ func (w *Watcher) WatchVariable(ctx context.Context, prev driver.State) (driver. waitIndex = nextBlockingIndex(meta.LastIndex) continue } - w.failures = 0 return &state{err: nfErr, modifyIndex: meta.LastIndex, meta: meta, updated: now}, 0 } @@ -63,7 +65,6 @@ func (w *Watcher) WatchVariable(ctx context.Context, prev driver.State) (driver. } v, decErr := w.decoder.Decode(ctx, kv.Value) - w.failures = 0 if decErr != nil { if prevErr != nil && prevErr.Error() == decErr.Error() { waitIndex = kv.ModifyIndex