Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,27 @@ 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
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

Expand Down
17 changes: 17 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
64 changes: 64 additions & 0 deletions consulvar/allowstale_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions consulvar/consulvar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions consulvar/internal/driver/watch_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions consulvar/internal/driver/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand All @@ -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
Expand Down
Loading