Skip to content

Commit a6fd22f

Browse files
fix(git): private cache dir, LRU eviction, and configurable location (#61)
The git cache lived under a world-readable /tmp path, was created 0750, and grew without bound — every unique repo+branch+revision left a directory for the pod's lifetime. - Create the cache root with 0700 (enforced via Chmod over umask) so cached repo contents and .git are unreadable by other users in the pod. - Move the default cache root to os.UserCacheDir(), falling back to the temp dir, and add PROVIDER_KUBECONFIG_CACHE_DIR to point it at a dedicated writable volume. - Bound the cache with LRU eviction (PROVIDER_KUBECONFIG_CACHE_MAX_ENTRIES, default 32): each access touches the dir mtime, and EnsureCloned evicts the oldest entries beyond the cap. Eviction skips directories locked by an in-flight reconcile (TryLock) and never removes the just-used dir. Tests: LRU eviction order, locked-dir skip, and a real local clone asserting the cache root is 0700 and the access token is not persisted to .git/config. Documents the env vars in the README. Closes #61 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c6614ae commit a6fd22f

3 files changed

Lines changed: 280 additions & 6 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,15 @@ The provider bootstraps its own RBAC on startup to manage downstream ProviderCon
329329

330330
This is automatic — no manual RBAC setup required. On provider upgrades (new pod/SA name), the bootstrap appends the new SA to the binding.
331331

332+
## Git Cache
333+
334+
Git sources are cloned into a per-repo cache directory. The cache root is created with `0700` permissions so cached repo contents (kubeconfigs, `.git`) are not readable by other users sharing the pod. Least-recently-used directories are evicted once the entry count exceeds a cap, bounding disk usage on long-running pods.
335+
336+
| Env var | Default | Description |
337+
|---------|---------|-------------|
338+
| `PROVIDER_KUBECONFIG_CACHE_DIR` | `$XDG_CACHE_HOME/provider-kubeconfig` (else `$TMPDIR/provider-kubeconfig`) | Cache root. Point at a dedicated writable volume (e.g. an `emptyDir`) to keep clones off shared `/tmp`. |
339+
| `PROVIDER_KUBECONFIG_CACHE_MAX_ENTRIES` | `32` | Max cached repo directories retained before LRU eviction. |
340+
332341
## Building
333342

334343
### Prerequisites

internal/git/git.go

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,137 @@ import (
2222
"fmt"
2323
"os"
2424
"path/filepath"
25+
"sort"
26+
"strconv"
2527
"sync"
28+
"time"
2629

2730
"github.com/go-git/go-git/v5"
2831
"github.com/go-git/go-git/v5/plumbing"
2932
"github.com/go-git/go-git/v5/plumbing/transport/http"
3033
"github.com/pkg/errors"
3134
)
3235

36+
const (
37+
// envCacheDir overrides the cache root location. Point it at a dedicated
38+
// writable volume (e.g. an emptyDir) to keep cached repos off the node's
39+
// shared /tmp.
40+
envCacheDir = "PROVIDER_KUBECONFIG_CACHE_DIR"
41+
// envMaxCacheEntries overrides the maximum number of cached repo
42+
// directories retained before least-recently-used eviction kicks in.
43+
envMaxCacheEntries = "PROVIDER_KUBECONFIG_CACHE_MAX_ENTRIES"
44+
// defaultMaxCacheEntries bounds the cache so a long-running pod that
45+
// reconciles many distinct repos does not grow the cache without limit.
46+
defaultMaxCacheEntries = 32
47+
// cacheDirPerm keeps cached repo contents (kubeconfigs, .git) unreadable by
48+
// other users sharing the pod.
49+
cacheDirPerm = 0o700
50+
)
51+
3352
var (
3453
// muMap guards concurrent access to the same repo cache directory.
3554
muMap = make(map[string]*sync.Mutex)
3655
muMapMu sync.Mutex
3756
)
3857

58+
// cacheRoot returns the root directory under which per-repo caches live. It
59+
// prefers an explicit override, then the user cache dir, falling back to the
60+
// system temp dir. The root is always created with 0700 permissions.
61+
func cacheRoot() string {
62+
if v := os.Getenv(envCacheDir); v != "" {
63+
return v
64+
}
65+
if dir, err := os.UserCacheDir(); err == nil {
66+
return filepath.Join(dir, "provider-kubeconfig")
67+
}
68+
return filepath.Join(os.TempDir(), "provider-kubeconfig")
69+
}
70+
71+
// ensureCacheRoot creates the cache root with private permissions, enforcing
72+
// 0700 even when the process umask would otherwise widen it.
73+
func ensureCacheRoot(root string) error {
74+
if err := os.MkdirAll(root, cacheDirPerm); err != nil {
75+
return errors.Wrap(err, "cannot create cache root directory")
76+
}
77+
if err := os.Chmod(root, cacheDirPerm); err != nil {
78+
return errors.Wrap(err, "cannot set cache root permissions")
79+
}
80+
return nil
81+
}
82+
83+
// maxCacheEntries returns the configured cache entry cap, or the default.
84+
func maxCacheEntries() int {
85+
if v := os.Getenv(envMaxCacheEntries); v != "" {
86+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
87+
return n
88+
}
89+
}
90+
return defaultMaxCacheEntries
91+
}
92+
93+
type cacheEntry struct {
94+
path string
95+
mod time.Time
96+
}
97+
98+
// cacheEntriesByAge lists the subdirectories of root, sorted least-recently-used
99+
// (oldest mtime) first. Returns nil on any read error.
100+
func cacheEntriesByAge(root string) []cacheEntry {
101+
entries, err := os.ReadDir(root)
102+
if err != nil {
103+
return nil
104+
}
105+
dirs := make([]cacheEntry, 0, len(entries))
106+
for _, e := range entries {
107+
if !e.IsDir() {
108+
continue
109+
}
110+
info, err := e.Info()
111+
if err != nil {
112+
continue
113+
}
114+
dirs = append(dirs, cacheEntry{path: filepath.Join(root, e.Name()), mod: info.ModTime()})
115+
}
116+
sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod.Before(dirs[j].mod) })
117+
return dirs
118+
}
119+
120+
// evictCache bounds root to at most maxEntries directories, removing the
121+
// least-recently-used entries beyond the limit. Directories currently locked by
122+
// an in-flight reconcile are skipped (TryLock), and keep is never removed.
123+
// Best-effort: any error leaves the cache as-is.
124+
func evictCache(root, keep string, maxEntries int) {
125+
dirs := cacheEntriesByAge(root)
126+
excess := len(dirs) - maxEntries
127+
if excess <= 0 {
128+
return
129+
}
130+
131+
for _, d := range dirs {
132+
if excess <= 0 {
133+
break
134+
}
135+
if d.path == keep {
136+
continue
137+
}
138+
mu := repoMutex(d.path)
139+
if !mu.TryLock() {
140+
continue // another reconcile is using this directory
141+
}
142+
if err := os.RemoveAll(d.path); err == nil {
143+
excess--
144+
}
145+
mu.Unlock()
146+
}
147+
}
148+
149+
// touch updates a directory's mtime so recently-used caches sort as newest for
150+
// eviction purposes (LRU).
151+
func touch(path string) {
152+
now := time.Now()
153+
_ = os.Chtimes(path, now, now)
154+
}
155+
39156
func repoMutex(key string) *sync.Mutex {
40157
muMapMu.Lock()
41158
defer muMapMu.Unlock()
@@ -64,7 +181,7 @@ func NewRepo(url, branch, revision, token string) *Repo {
64181
branch = "main"
65182
}
66183
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(url+"@"+branch+"#"+revision)))[:16]
67-
cacheDir := filepath.Join(os.TempDir(), "provider-kubeconfig", hash)
184+
cacheDir := filepath.Join(cacheRoot(), hash)
68185
return &Repo{url: url, branch: branch, revision: revision, token: token, cacheDir: cacheDir}
69186
}
70187

@@ -80,12 +197,26 @@ func (r *Repo) auth() *http.BasicAuth {
80197

81198
// EnsureCloned clones the repo if not cached, or pulls latest if it is. When a
82199
// revision is pinned it delegates to ensureRevision, which checks out the exact
83-
// commit/tag instead of tracking the branch tip.
200+
// commit/tag instead of tracking the branch tip. On success it marks the cache
201+
// as recently used and evicts least-recently-used entries beyond the cap.
84202
func (r *Repo) EnsureCloned(ctx context.Context) (string, error) {
85203
mu := repoMutex(r.cacheDir)
86204
mu.Lock()
87205
defer mu.Unlock()
88206

207+
dir, err := r.ensure(ctx)
208+
if err != nil {
209+
return "", err
210+
}
211+
212+
touch(dir)
213+
evictCache(filepath.Dir(r.cacheDir), r.cacheDir, maxCacheEntries())
214+
return dir, nil
215+
}
216+
217+
// ensure performs the clone/pull (or pinned-revision checkout) and returns the
218+
// cache directory, without the LRU bookkeeping handled by EnsureCloned.
219+
func (r *Repo) ensure(ctx context.Context) (string, error) {
89220
if r.revision != "" {
90221
return r.ensureRevision(ctx)
91222
}
@@ -101,8 +232,8 @@ func (r *Repo) EnsureCloned(ctx context.Context) (string, error) {
101232
_ = os.RemoveAll(r.cacheDir)
102233
}
103234

104-
if err := os.MkdirAll(filepath.Dir(r.cacheDir), 0750); err != nil {
105-
return "", errors.Wrap(err, "cannot create cache parent directory")
235+
if err := ensureCacheRoot(filepath.Dir(r.cacheDir)); err != nil {
236+
return "", err
106237
}
107238

108239
opts := &git.CloneOptions{
@@ -131,8 +262,8 @@ func (r *Repo) ensureRevision(ctx context.Context) (string, error) {
131262
return r.cacheDir, nil
132263
}
133264

134-
if err := os.MkdirAll(filepath.Dir(r.cacheDir), 0750); err != nil {
135-
return "", errors.Wrap(err, "cannot create cache parent directory")
265+
if err := ensureCacheRoot(filepath.Dir(r.cacheDir)); err != nil {
266+
return "", err
136267
}
137268

138269
repo, err := git.PlainCloneContext(ctx, r.cacheDir, false, &git.CloneOptions{

internal/git/git_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ limitations under the License.
1717
package git
1818

1919
import (
20+
"context"
21+
"fmt"
2022
"os"
2123
"path/filepath"
24+
"strings"
2225
"testing"
26+
"time"
2327

28+
"github.com/go-git/go-git/v5"
29+
"github.com/go-git/go-git/v5/plumbing"
30+
"github.com/go-git/go-git/v5/plumbing/object"
2431
"github.com/google/go-cmp/cmp"
2532
)
2633

@@ -114,6 +121,133 @@ func TestAuth(t *testing.T) {
114121
}
115122
}
116123

124+
func TestEvictCacheLRU(t *testing.T) {
125+
root := t.TempDir()
126+
base := time.Unix(1_000_000, 0)
127+
paths := make([]string, 5)
128+
for i := range paths {
129+
p := filepath.Join(root, fmt.Sprintf("r%d", i))
130+
if err := os.MkdirAll(p, 0o700); err != nil {
131+
t.Fatalf("mkdir: %v", err)
132+
}
133+
mt := base.Add(time.Duration(i) * time.Hour)
134+
if err := os.Chtimes(p, mt, mt); err != nil {
135+
t.Fatalf("chtimes: %v", err)
136+
}
137+
paths[i] = p
138+
}
139+
140+
// Cap to 2 entries, keeping the newest. The 3 oldest must be evicted.
141+
evictCache(root, paths[4], 2)
142+
143+
exists := func(p string) bool { _, err := os.Stat(p); return err == nil }
144+
for _, i := range []int{0, 1, 2} {
145+
if exists(paths[i]) {
146+
t.Errorf("expected LRU entry %s to be evicted", paths[i])
147+
}
148+
}
149+
for _, i := range []int{3, 4} {
150+
if !exists(paths[i]) {
151+
t.Errorf("expected recent entry %s to be retained", paths[i])
152+
}
153+
}
154+
}
155+
156+
func TestEvictCacheSkipsLocked(t *testing.T) {
157+
root := t.TempDir()
158+
base := time.Unix(1_000_000, 0)
159+
paths := make([]string, 3)
160+
for i := range paths {
161+
p := filepath.Join(root, fmt.Sprintf("r%d", i))
162+
if err := os.MkdirAll(p, 0o700); err != nil {
163+
t.Fatalf("mkdir: %v", err)
164+
}
165+
mt := base.Add(time.Duration(i) * time.Hour)
166+
if err := os.Chtimes(p, mt, mt); err != nil {
167+
t.Fatalf("chtimes: %v", err)
168+
}
169+
paths[i] = p
170+
}
171+
172+
// Lock the oldest entry: eviction must skip it even though it is the
173+
// least-recently-used, because another reconcile is using it.
174+
mu := repoMutex(paths[0])
175+
mu.Lock()
176+
defer mu.Unlock()
177+
178+
evictCache(root, "", 1)
179+
180+
if _, err := os.Stat(paths[0]); err != nil {
181+
t.Error("locked dir must not be evicted")
182+
}
183+
if _, err := os.Stat(paths[1]); err == nil {
184+
t.Error("unlocked LRU dir should have been evicted")
185+
}
186+
}
187+
188+
// initSourceRepo creates a local non-bare git repo with one commit on `main`
189+
// and returns its path and the commit hash.
190+
func initSourceRepo(t *testing.T) (string, string) {
191+
t.Helper()
192+
dir := t.TempDir()
193+
repo, err := git.PlainInitWithOptions(dir, &git.PlainInitOptions{
194+
InitOptions: git.InitOptions{DefaultBranch: plumbing.NewBranchReferenceName("main")},
195+
})
196+
if err != nil {
197+
t.Fatalf("init source repo: %v", err)
198+
}
199+
wt, err := repo.Worktree()
200+
if err != nil {
201+
t.Fatalf("worktree: %v", err)
202+
}
203+
if err := os.WriteFile(filepath.Join(dir, "kubeconfig.yaml"), []byte("data"), 0o600); err != nil {
204+
t.Fatalf("write file: %v", err)
205+
}
206+
if _, err := wt.Add("kubeconfig.yaml"); err != nil {
207+
t.Fatalf("add: %v", err)
208+
}
209+
h, err := wt.Commit("init", &git.CommitOptions{
210+
Author: &object.Signature{Name: "t", Email: "t@example.com", When: time.Unix(0, 0)},
211+
})
212+
if err != nil {
213+
t.Fatalf("commit: %v", err)
214+
}
215+
return dir, h.String()
216+
}
217+
218+
func TestEnsureClonedPrivateCacheAndNoCredentialLeak(t *testing.T) {
219+
src, hash := initSourceRepo(t)
220+
221+
// Point the cache at a not-yet-existing dir so ensureCacheRoot must create
222+
// it, and we can assert the permissions it applies.
223+
root := filepath.Join(t.TempDir(), "cache")
224+
t.Setenv(envCacheDir, root)
225+
226+
const token = "SUPER-SECRET-TOKEN"
227+
// Pin to the commit hash so the (local-friendly) full-clone path runs.
228+
r := NewRepo(src, "main", hash, token)
229+
dir, err := r.EnsureCloned(context.Background())
230+
if err != nil {
231+
t.Fatalf("EnsureCloned: %v", err)
232+
}
233+
234+
info, err := os.Stat(root)
235+
if err != nil {
236+
t.Fatalf("stat cache root: %v", err)
237+
}
238+
if perm := info.Mode().Perm(); perm != cacheDirPerm {
239+
t.Errorf("cache root permissions: want %#o, got %#o", cacheDirPerm, perm)
240+
}
241+
242+
cfg, err := os.ReadFile(filepath.Join(dir, ".git", "config")) //nolint:gosec // test-controlled path
243+
if err != nil {
244+
t.Fatalf("read .git/config: %v", err)
245+
}
246+
if strings.Contains(string(cfg), token) {
247+
t.Error(".git/config leaked the access token")
248+
}
249+
}
250+
117251
func TestReadFile(t *testing.T) {
118252
// Create a temp directory to act as a fake cloned repo.
119253
tmpDir := t.TempDir()

0 commit comments

Comments
 (0)