Skip to content

Commit 92984f3

Browse files
Feat/bazel (#34)
* add bazel * Bump Chart version * Update Bazel put
1 parent 042f3f9 commit 92984f3

11 files changed

Lines changed: 365 additions & 7 deletions

File tree

chart/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: A Gradle Build Cache server with Redis backend for Theia IDE deploy
55
type: application
66

77
# Chart version - bump for breaking changes
8-
version: 0.3.1
8+
version: 0.4.0
99

1010
# Application version - matches the cache server version
1111
appVersion: "0.1.0"

chart/templates/configmap.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ data:
2323
addr: "{{ .Release.Name }}-redis:6379"
2424
2525
cache:
26-
max_entry_size_mb: 100
26+
max_entry_size_mb: {{ .Values.cache.maxEntrySizeMB }}
27+
verify_cas_hash: {{ .Values.cache.verifyCASHash }}
2728
2829
auth:
2930
enabled: {{ .Values.auth.enabled }}

chart/values.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ resources:
4343
memory: "2Gi"
4444
cpu: "1000m"
4545

46+
# Cache settings (shared by Gradle and Bazel)
47+
cache:
48+
# Maximum entry size in MB
49+
maxEntrySizeMB: 100
50+
# Verify SHA-256 hash of Bazel CAS blobs on PUT
51+
verifyCASHash: true
52+
4653
tls:
4754
# Enable TLS for the cache server
4855
enabled: false

src/configs/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ storage:
1010

1111
cache:
1212
max_entry_size_mb: 100
13+
verify_cas_hash: true
1314

1415
auth:
1516
enabled: true

src/internal/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type StorageConfig struct {
3939

4040
type CacheConfig struct {
4141
MaxEntrySizeMB int64 `mapstructure:"max_entry_size_mb"`
42+
VerifyCASHash bool `mapstructure:"verify_cas_hash"`
4243
}
4344

4445
type AuthConfig struct {
@@ -82,6 +83,7 @@ func Load(configPath string) (*Config, error) {
8283
v.SetDefault("storage.db", 0)
8384

8485
v.SetDefault("cache.max_entry_size_mb", 100)
86+
v.SetDefault("cache.verify_cas_hash", true)
8587

8688
v.SetDefault("auth.enabled", true)
8789

@@ -146,3 +148,4 @@ func (c *Config) Validate() error {
146148
func (c *Config) MaxEntrySizeBytes() int64 {
147149
return c.Cache.MaxEntrySizeMB * 1024 * 1024
148150
}
151+

src/internal/handler/bazel_get.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package handler
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
7+
"github.com/gin-gonic/gin"
8+
"github.com/kevingruber/gradle-cache/internal/storage"
9+
"go.opentelemetry.io/otel/attribute"
10+
"go.opentelemetry.io/otel/metric"
11+
)
12+
13+
// GetAC handles GET requests for Bazel action cache entries.
14+
func (h *BazelHandler) GetAC(c *gin.Context) {
15+
h.get(c, h.acStorage, "ac")
16+
}
17+
18+
// GetCAS handles GET requests for Bazel content-addressable storage entries.
19+
func (h *BazelHandler) GetCAS(c *gin.Context) {
20+
h.get(c, h.casStorage, "cas")
21+
}
22+
23+
func (h *BazelHandler) get(c *gin.Context, store storage.Storage, cacheType string) {
24+
hash := c.Param("hash")
25+
if !isValidSHA256Hex(hash) {
26+
c.Status(http.StatusBadRequest)
27+
return
28+
}
29+
30+
attrs := metric.WithAttributes(attribute.String("cache_type", cacheType))
31+
32+
reader, size, err := store.Get(c.Request.Context(), hash)
33+
if err != nil {
34+
if errors.Is(err, storage.ErrNotFound) {
35+
h.metrics.CacheMisses.Add(c.Request.Context(), 1, attrs)
36+
c.Status(http.StatusNotFound)
37+
return
38+
}
39+
h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to get bazel cache entry")
40+
c.Status(http.StatusInternalServerError)
41+
return
42+
}
43+
defer reader.Close()
44+
45+
h.metrics.CacheHits.Add(c.Request.Context(), 1, attrs)
46+
c.DataFromReader(http.StatusOK, size, "application/octet-stream", reader, nil)
47+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package handler
2+
3+
import (
4+
"github.com/kevingruber/gradle-cache/internal/storage"
5+
"github.com/rs/zerolog"
6+
)
7+
8+
// BazelHandler handles Bazel HTTP remote cache requests.
9+
// Bazel uses two namespaces: /ac/ (action cache) and /cas/ (content-addressable storage).
10+
type BazelHandler struct {
11+
acStorage storage.Storage
12+
casStorage storage.Storage
13+
maxEntrySize int64
14+
verifyCAS bool
15+
logger zerolog.Logger
16+
metrics *BazelMetrics
17+
}
18+
19+
// NewBazelHandler creates a new Bazel cache handler.
20+
// The store must implement NamespacedStorage to isolate AC and CAS keys.
21+
func NewBazelHandler(store storage.NamespacedStorage, maxEntrySize int64, verifyCAS bool, logger zerolog.Logger) (*BazelHandler, error) {
22+
metrics, err := NewBazelMetrics()
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
return &BazelHandler{
28+
acStorage: store.WithNamespace("bazel:ac"),
29+
casStorage: store.WithNamespace("bazel:cas"),
30+
maxEntrySize: maxEntrySize,
31+
verifyCAS: verifyCAS,
32+
logger: logger,
33+
metrics: metrics,
34+
}, nil
35+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package handler
2+
3+
import (
4+
"go.opentelemetry.io/otel"
5+
"go.opentelemetry.io/otel/metric"
6+
)
7+
8+
type BazelMetrics struct {
9+
CacheHits metric.Int64Counter
10+
CacheMisses metric.Int64Counter
11+
HashMismatches metric.Int64Counter
12+
EntrySize metric.Float64Histogram
13+
}
14+
15+
func NewBazelMetrics() (*BazelMetrics, error) {
16+
meter := otel.Meter("bazel-cache")
17+
18+
cacheHits, err := meter.Int64Counter(
19+
"bazel_cache.cache_hits",
20+
metric.WithDescription("Total number of Bazel cache hits"))
21+
if err != nil {
22+
return nil, err
23+
}
24+
25+
cacheMisses, err := meter.Int64Counter(
26+
"bazel_cache.cache_misses",
27+
metric.WithDescription("Total number of Bazel cache misses"))
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
hashMismatches, err := meter.Int64Counter(
33+
"bazel_cache.hash_mismatches",
34+
metric.WithDescription("Total number of CAS hash verification failures"))
35+
if err != nil {
36+
return nil, err
37+
}
38+
39+
entrySize, err := meter.Float64Histogram(
40+
"bazel_cache.entry_size",
41+
metric.WithDescription("Size of Bazel cache entries in bytes"))
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
return &BazelMetrics{
47+
CacheHits: cacheHits,
48+
CacheMisses: cacheMisses,
49+
HashMismatches: hashMismatches,
50+
EntrySize: entrySize,
51+
}, nil
52+
}

src/internal/handler/bazel_put.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package handler
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
11+
"github.com/gin-gonic/gin"
12+
"github.com/kevingruber/gradle-cache/internal/storage"
13+
"go.opentelemetry.io/otel/attribute"
14+
"go.opentelemetry.io/otel/metric"
15+
)
16+
17+
// PutAC handles PUT requests to store Bazel action cache entries.
18+
func (h *BazelHandler) PutAC(c *gin.Context) {
19+
h.put(c, h.acStorage, "ac", false)
20+
}
21+
22+
// PutCAS handles PUT requests to store Bazel CAS entries.
23+
// If verifyCAS is enabled, the content hash is verified against the URL hash.
24+
func (h *BazelHandler) PutCAS(c *gin.Context) {
25+
h.put(c, h.casStorage, "cas", h.verifyCAS)
26+
}
27+
28+
func (h *BazelHandler) put(c *gin.Context, store storage.Storage, cacheType string, verifyHash bool) {
29+
hash := c.Param("hash")
30+
if !isValidSHA256Hex(hash) {
31+
c.Status(http.StatusBadRequest)
32+
return
33+
}
34+
35+
attrs := metric.WithAttributes(attribute.String("cache_type", cacheType))
36+
37+
// Early rejection if Content-Length is known and too large
38+
contentLength := c.Request.ContentLength
39+
if contentLength > h.maxEntrySize {
40+
h.logger.Warn().
41+
Str("hash", hash).
42+
Str("cache_type", cacheType).
43+
Int64("size", contentLength).
44+
Int64("max_size", h.maxEntrySize).
45+
Msg("bazel cache entry too large")
46+
c.Status(http.StatusRequestEntityTooLarge)
47+
return
48+
}
49+
50+
if verifyHash {
51+
h.putWithVerify(c, store, hash, cacheType, attrs)
52+
} else {
53+
h.putDirect(c, store, hash, cacheType, contentLength, attrs)
54+
}
55+
}
56+
57+
// putDirect streams the request body to storage without hash verification.
58+
// If Content-Length is known, streams directly. Otherwise spools to a temp file.
59+
func (h *BazelHandler) putDirect(c *gin.Context, store storage.Storage, hash, cacheType string, contentLength int64, attrs metric.MeasurementOption) {
60+
if contentLength >= 0 {
61+
// Content-Length known: stream directly to storage
62+
limited := io.LimitReader(c.Request.Body, contentLength)
63+
h.metrics.EntrySize.Record(c.Request.Context(), float64(contentLength), attrs)
64+
65+
if err := store.Put(c.Request.Context(), hash, limited, contentLength); err != nil {
66+
h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry")
67+
c.Status(http.StatusInternalServerError)
68+
return
69+
}
70+
c.Status(http.StatusOK)
71+
return
72+
}
73+
74+
// Chunked transfer: spool to temp file to determine size
75+
size, reader, cleanup, err := h.spoolToTempFile(c.Request.Body)
76+
if cleanup != nil {
77+
defer cleanup()
78+
}
79+
if err != nil {
80+
h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to read request body")
81+
c.Status(http.StatusInternalServerError)
82+
return
83+
}
84+
if size > h.maxEntrySize {
85+
c.Status(http.StatusRequestEntityTooLarge)
86+
return
87+
}
88+
89+
h.metrics.EntrySize.Record(c.Request.Context(), float64(size), attrs)
90+
91+
if err := store.Put(c.Request.Context(), hash, reader, size); err != nil {
92+
h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry")
93+
c.Status(http.StatusInternalServerError)
94+
return
95+
}
96+
c.Status(http.StatusOK)
97+
}
98+
99+
// putWithVerify spools the upload to a temp file while computing the SHA-256 hash,
100+
// then verifies the hash before storing.
101+
func (h *BazelHandler) putWithVerify(c *gin.Context, store storage.Storage, hash, cacheType string, attrs metric.MeasurementOption) {
102+
f, err := os.CreateTemp("", "bazel-cas-*")
103+
if err != nil {
104+
h.logger.Error().Err(err).Msg("failed to create temp file for CAS verification")
105+
c.Status(http.StatusInternalServerError)
106+
return
107+
}
108+
defer os.Remove(f.Name())
109+
defer f.Close()
110+
111+
hasher := sha256.New()
112+
limited := io.LimitReader(c.Request.Body, h.maxEntrySize+1)
113+
tee := io.TeeReader(limited, hasher)
114+
115+
written, err := io.Copy(f, tee)
116+
if err != nil {
117+
h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to read request body")
118+
c.Status(http.StatusInternalServerError)
119+
return
120+
}
121+
122+
if written > h.maxEntrySize {
123+
c.Status(http.StatusRequestEntityTooLarge)
124+
return
125+
}
126+
127+
computedHex := hex.EncodeToString(hasher.Sum(nil))
128+
if computedHex != hash {
129+
h.metrics.HashMismatches.Add(c.Request.Context(), 1, attrs)
130+
h.logger.Warn().
131+
Str("expected", hash).
132+
Str("computed", computedHex).
133+
Msg("bazel CAS hash mismatch")
134+
c.Status(http.StatusBadRequest)
135+
return
136+
}
137+
138+
if _, err := f.Seek(0, io.SeekStart); err != nil {
139+
h.logger.Error().Err(err).Msg("failed to seek temp file")
140+
c.Status(http.StatusInternalServerError)
141+
return
142+
}
143+
144+
h.metrics.EntrySize.Record(c.Request.Context(), float64(written), attrs)
145+
146+
if err := store.Put(c.Request.Context(), hash, f, written); err != nil {
147+
h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry")
148+
c.Status(http.StatusInternalServerError)
149+
return
150+
}
151+
c.Status(http.StatusOK)
152+
}
153+
154+
// spoolToTempFile copies from r (limited to maxEntrySize+1) into a temp file
155+
// and returns the written size, a reader seeked to start, and a cleanup function.
156+
func (h *BazelHandler) spoolToTempFile(r io.Reader) (int64, io.Reader, func(), error) {
157+
f, err := os.CreateTemp("", "bazel-spool-*")
158+
if err != nil {
159+
return 0, nil, nil, fmt.Errorf("create temp file: %w", err)
160+
}
161+
cleanup := func() {
162+
f.Close()
163+
os.Remove(f.Name())
164+
}
165+
166+
limited := io.LimitReader(r, h.maxEntrySize+1)
167+
written, err := io.Copy(f, limited)
168+
if err != nil {
169+
return 0, nil, cleanup, fmt.Errorf("spool to temp file: %w", err)
170+
}
171+
172+
if _, err := f.Seek(0, io.SeekStart); err != nil {
173+
return 0, nil, cleanup, fmt.Errorf("seek temp file: %w", err)
174+
}
175+
176+
return written, f, cleanup, nil
177+
}

src/internal/handler/validation.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package handler
2+
3+
// isValidSHA256Hex returns true if s is a valid lowercase hex-encoded SHA-256 hash (64 characters).
4+
func isValidSHA256Hex(s string) bool {
5+
if len(s) != 64 {
6+
return false
7+
}
8+
for _, c := range s {
9+
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
10+
return false
11+
}
12+
}
13+
return true
14+
}

0 commit comments

Comments
 (0)