mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
Add a "Response Cache" section to the agent configuration reference and strengthen the file-backed cache so writes are not just atomic but also durable. - docs/configuration/agents/index.md: extend the schema example and property table with the new `cache` field, and add a dedicated "Response Cache" section describing the matching options (case_sensitive, trim_spaces), the storage backends (in-memory vs JSON file), and the atomicity guarantees of the file-backed store. - pkg/cache: fsync the temp file before rename and fsync the parent directory after rename so the rename itself is persisted across an OS crash. Concurrent readers are still guaranteed to see either the previous or the new content in full, never a torn write. - pkg/cache: add tests asserting that the success path leaves no leftover temp files and that a parallel reader never observes a partially written cache file under sustained concurrent stores. Assisted-By: docker-agent
197 lines
4.9 KiB
Go
197 lines
4.9 KiB
Go
package cache
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNew_disabled(t *testing.T) {
|
|
c, err := New(Config{Enabled: false})
|
|
require.NoError(t, err)
|
|
assert.Nil(t, c)
|
|
}
|
|
|
|
func TestMemoryCache_caseSensitiveDefault(t *testing.T) {
|
|
c, err := New(Config{Enabled: true, CaseSensitive: true})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, c)
|
|
|
|
c.Store("Hello", "world")
|
|
|
|
got, ok := c.Lookup("Hello")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "world", got)
|
|
|
|
_, ok = c.Lookup("hello")
|
|
assert.False(t, ok, "case-sensitive cache should not match different case")
|
|
}
|
|
|
|
func TestMemoryCache_caseInsensitive(t *testing.T) {
|
|
c, err := New(Config{Enabled: true, CaseSensitive: false})
|
|
require.NoError(t, err)
|
|
|
|
c.Store("Hello", "world")
|
|
|
|
got, ok := c.Lookup("HELLO")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "world", got)
|
|
}
|
|
|
|
func TestMemoryCache_trimSpaces(t *testing.T) {
|
|
c, err := New(Config{Enabled: true, TrimSpaces: true})
|
|
require.NoError(t, err)
|
|
|
|
c.Store(" hello ", "world")
|
|
|
|
got, ok := c.Lookup("hello")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "world", got)
|
|
|
|
got, ok = c.Lookup("\thello\n")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "world", got)
|
|
}
|
|
|
|
func TestMemoryCache_noTrimByDefault(t *testing.T) {
|
|
c, err := New(Config{Enabled: true})
|
|
require.NoError(t, err)
|
|
|
|
c.Store(" hello ", "world")
|
|
|
|
_, ok := c.Lookup("hello")
|
|
assert.False(t, ok, "without TrimSpaces, whitespace must be significant")
|
|
}
|
|
|
|
func TestMemoryCache_overwrite(t *testing.T) {
|
|
c, err := New(Config{Enabled: true})
|
|
require.NoError(t, err)
|
|
|
|
c.Store("q", "first")
|
|
c.Store("q", "second")
|
|
|
|
got, ok := c.Lookup("q")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "second", got)
|
|
}
|
|
|
|
func TestFileCache_persists(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "cache.json")
|
|
|
|
c1, err := New(Config{Enabled: true, Path: path, CaseSensitive: false, TrimSpaces: true})
|
|
require.NoError(t, err)
|
|
c1.Store(" Hello ", "world")
|
|
|
|
// File must exist on disk and contain the normalized key.
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
var entries map[string]string
|
|
require.NoError(t, json.Unmarshal(data, &entries))
|
|
assert.Equal(t, map[string]string{"hello": "world"}, entries)
|
|
|
|
// A new cache loaded from the same file recovers the entries.
|
|
c2, err := New(Config{Enabled: true, Path: path, CaseSensitive: false, TrimSpaces: true})
|
|
require.NoError(t, err)
|
|
got, ok := c2.Lookup("HELLO")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "world", got)
|
|
}
|
|
|
|
func TestFileCache_missingFileIsFine(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "nested", "cache.json")
|
|
|
|
c, err := New(Config{Enabled: true, Path: path})
|
|
require.NoError(t, err)
|
|
|
|
_, ok := c.Lookup("anything")
|
|
assert.False(t, ok)
|
|
|
|
c.Store("hello", "world")
|
|
|
|
got, ok := c.Lookup("hello")
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "world", got)
|
|
|
|
// And the directory should have been created.
|
|
_, err = os.Stat(path)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestFileCache_corruptFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "cache.json")
|
|
require.NoError(t, os.WriteFile(path, []byte("not json"), 0o600))
|
|
|
|
_, err := New(Config{Enabled: true, Path: path})
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// TestFileCache_atomicWriteLeavesNoTempFiles verifies that the rename-based
|
|
// atomic write does not leak temporary files on the happy path.
|
|
func TestFileCache_atomicWriteLeavesNoTempFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "cache.json")
|
|
|
|
c, err := New(Config{Enabled: true, Path: path})
|
|
require.NoError(t, err)
|
|
|
|
for i := range 5 {
|
|
c.Store(fmt.Sprintf("q%d", i), fmt.Sprintf("a%d", i))
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
require.NoError(t, err)
|
|
|
|
for _, e := range entries {
|
|
assert.Equal(t, "cache.json", e.Name(),
|
|
"unexpected leftover in cache directory: %q", e.Name())
|
|
}
|
|
}
|
|
|
|
// TestFileCache_concurrentStoreNeverYieldsTornFile verifies that concurrent
|
|
// Store calls always leave a fully valid JSON file behind — i.e. a parallel
|
|
// reader will never observe a half-written cache thanks to the
|
|
// rename-over-temp atomicity.
|
|
func TestFileCache_concurrentStoreNeverYieldsTornFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "cache.json")
|
|
|
|
c, err := New(Config{Enabled: true, Path: path})
|
|
require.NoError(t, err)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for i := range 50 {
|
|
c.Store(fmt.Sprintf("q%d", i), fmt.Sprintf("a%d", i))
|
|
}
|
|
}()
|
|
|
|
// While writes are happening, repeatedly read and parse the file.
|
|
// Without atomic rename, this would intermittently see truncated /
|
|
// half-written content and json.Unmarshal would error.
|
|
for range 100 {
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
require.NoError(t, err)
|
|
if len(data) == 0 {
|
|
continue
|
|
}
|
|
var m map[string]string
|
|
require.NoError(t, json.Unmarshal(data, &m),
|
|
"reader observed a torn write: %q", string(data))
|
|
}
|
|
|
|
<-done
|
|
}
|