From 273ad27d0bba4637911636201dc84cf9c304f132 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 24 Mar 2026 15:26:56 +0100 Subject: [PATCH 1/5] add bazel --- chart/templates/configmap.yaml | 3 +- chart/values.yaml | 7 +++ src/configs/config.yaml | 1 + src/internal/config/config.go | 3 + src/internal/handler/bazel_get.go | 47 ++++++++++++++ src/internal/handler/bazel_handler.go | 35 +++++++++++ src/internal/handler/bazel_metrics.go | 52 ++++++++++++++++ src/internal/handler/bazel_put.go | 90 +++++++++++++++++++++++++++ src/internal/handler/validation.go | 14 +++++ src/internal/server/server.go | 31 +++++++-- 10 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 src/internal/handler/bazel_get.go create mode 100644 src/internal/handler/bazel_handler.go create mode 100644 src/internal/handler/bazel_metrics.go create mode 100644 src/internal/handler/bazel_put.go create mode 100644 src/internal/handler/validation.go diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index bef45c2..2ca153b 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -23,7 +23,8 @@ data: addr: "{{ .Release.Name }}-redis:6379" cache: - max_entry_size_mb: 100 + max_entry_size_mb: {{ .Values.cache.maxEntrySizeMB }} + verify_cas_hash: {{ .Values.cache.verifyCASHash }} auth: enabled: {{ .Values.auth.enabled }} diff --git a/chart/values.yaml b/chart/values.yaml index b87db7a..0269a8a 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -43,6 +43,13 @@ resources: memory: "2Gi" cpu: "1000m" +# Cache settings (shared by Gradle and Bazel) +cache: + # Maximum entry size in MB + maxEntrySizeMB: 100 + # Verify SHA-256 hash of Bazel CAS blobs on PUT + verifyCASHash: true + tls: # Enable TLS for the cache server enabled: false diff --git a/src/configs/config.yaml b/src/configs/config.yaml index aa5217e..223e24e 100644 --- a/src/configs/config.yaml +++ b/src/configs/config.yaml @@ -10,6 +10,7 @@ storage: cache: max_entry_size_mb: 100 + verify_cas_hash: true auth: enabled: true diff --git a/src/internal/config/config.go b/src/internal/config/config.go index d797ce3..5079439 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -39,6 +39,7 @@ type StorageConfig struct { type CacheConfig struct { MaxEntrySizeMB int64 `mapstructure:"max_entry_size_mb"` + VerifyCASHash bool `mapstructure:"verify_cas_hash"` } type AuthConfig struct { @@ -82,6 +83,7 @@ func Load(configPath string) (*Config, error) { v.SetDefault("storage.db", 0) v.SetDefault("cache.max_entry_size_mb", 100) + v.SetDefault("cache.verify_cas_hash", true) v.SetDefault("auth.enabled", true) @@ -146,3 +148,4 @@ func (c *Config) Validate() error { func (c *Config) MaxEntrySizeBytes() int64 { return c.Cache.MaxEntrySizeMB * 1024 * 1024 } + diff --git a/src/internal/handler/bazel_get.go b/src/internal/handler/bazel_get.go new file mode 100644 index 0000000..8f58b2c --- /dev/null +++ b/src/internal/handler/bazel_get.go @@ -0,0 +1,47 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kevingruber/gradle-cache/internal/storage" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// GetAC handles GET requests for Bazel action cache entries. +func (h *BazelHandler) GetAC(c *gin.Context) { + h.get(c, h.acStorage, "ac") +} + +// GetCAS handles GET requests for Bazel content-addressable storage entries. +func (h *BazelHandler) GetCAS(c *gin.Context) { + h.get(c, h.casStorage, "cas") +} + +func (h *BazelHandler) get(c *gin.Context, store storage.Storage, cacheType string) { + hash := c.Param("hash") + if !isValidSHA256Hex(hash) { + c.Status(http.StatusBadRequest) + return + } + + attrs := metric.WithAttributes(attribute.String("cache_type", cacheType)) + + reader, size, err := store.Get(c.Request.Context(), hash) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + h.metrics.CacheMisses.Add(c.Request.Context(), 1, attrs) + c.Status(http.StatusNotFound) + return + } + h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to get bazel cache entry") + c.Status(http.StatusInternalServerError) + return + } + defer reader.Close() + + h.metrics.CacheHits.Add(c.Request.Context(), 1, attrs) + c.DataFromReader(http.StatusOK, size, "application/octet-stream", reader, nil) +} diff --git a/src/internal/handler/bazel_handler.go b/src/internal/handler/bazel_handler.go new file mode 100644 index 0000000..53b5cb8 --- /dev/null +++ b/src/internal/handler/bazel_handler.go @@ -0,0 +1,35 @@ +package handler + +import ( + "github.com/kevingruber/gradle-cache/internal/storage" + "github.com/rs/zerolog" +) + +// BazelHandler handles Bazel HTTP remote cache requests. +// Bazel uses two namespaces: /ac/ (action cache) and /cas/ (content-addressable storage). +type BazelHandler struct { + acStorage storage.Storage + casStorage storage.Storage + maxEntrySize int64 + verifyCAS bool + logger zerolog.Logger + metrics *BazelMetrics +} + +// NewBazelHandler creates a new Bazel cache handler. +// The store must implement NamespacedStorage to isolate AC and CAS keys. +func NewBazelHandler(store storage.NamespacedStorage, maxEntrySize int64, verifyCAS bool, logger zerolog.Logger) (*BazelHandler, error) { + metrics, err := NewBazelMetrics() + if err != nil { + return nil, err + } + + return &BazelHandler{ + acStorage: store.WithNamespace("bazel:ac"), + casStorage: store.WithNamespace("bazel:cas"), + maxEntrySize: maxEntrySize, + verifyCAS: verifyCAS, + logger: logger, + metrics: metrics, + }, nil +} diff --git a/src/internal/handler/bazel_metrics.go b/src/internal/handler/bazel_metrics.go new file mode 100644 index 0000000..fd9b23f --- /dev/null +++ b/src/internal/handler/bazel_metrics.go @@ -0,0 +1,52 @@ +package handler + +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +type BazelMetrics struct { + CacheHits metric.Int64Counter + CacheMisses metric.Int64Counter + HashMismatches metric.Int64Counter + EntrySize metric.Float64Histogram +} + +func NewBazelMetrics() (*BazelMetrics, error) { + meter := otel.Meter("bazel-cache") + + cacheHits, err := meter.Int64Counter( + "bazel_cache.cache_hits", + metric.WithDescription("Total number of Bazel cache hits")) + if err != nil { + return nil, err + } + + cacheMisses, err := meter.Int64Counter( + "bazel_cache.cache_misses", + metric.WithDescription("Total number of Bazel cache misses")) + if err != nil { + return nil, err + } + + hashMismatches, err := meter.Int64Counter( + "bazel_cache.hash_mismatches", + metric.WithDescription("Total number of CAS hash verification failures")) + if err != nil { + return nil, err + } + + entrySize, err := meter.Float64Histogram( + "bazel_cache.entry_size", + metric.WithDescription("Size of Bazel cache entries in bytes")) + if err != nil { + return nil, err + } + + return &BazelMetrics{ + CacheHits: cacheHits, + CacheMisses: cacheMisses, + HashMismatches: hashMismatches, + EntrySize: entrySize, + }, nil +} diff --git a/src/internal/handler/bazel_put.go b/src/internal/handler/bazel_put.go new file mode 100644 index 0000000..aae16ee --- /dev/null +++ b/src/internal/handler/bazel_put.go @@ -0,0 +1,90 @@ +package handler + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kevingruber/gradle-cache/internal/storage" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// PutAC handles PUT requests to store Bazel action cache entries. +func (h *BazelHandler) PutAC(c *gin.Context) { + h.put(c, h.acStorage, "ac", false) +} + +// PutCAS handles PUT requests to store Bazel CAS entries. +// If verifyCAS is enabled, the content hash is verified against the URL hash. +func (h *BazelHandler) PutCAS(c *gin.Context) { + h.put(c, h.casStorage, "cas", h.verifyCAS) +} + +func (h *BazelHandler) put(c *gin.Context, store storage.Storage, cacheType string, verifyHash bool) { + hash := c.Param("hash") + if !isValidSHA256Hex(hash) { + c.Status(http.StatusBadRequest) + return + } + + attrs := metric.WithAttributes(attribute.String("cache_type", cacheType)) + + // Check Content-Length for size validation + contentLength := c.Request.ContentLength + if contentLength > h.maxEntrySize { + h.logger.Warn(). + Str("hash", hash). + Str("cache_type", cacheType). + Int64("size", contentLength). + Int64("max_size", h.maxEntrySize). + Msg("bazel cache entry too large") + c.Status(http.StatusRequestEntityTooLarge) + return + } + + // Read the body (needed for both hash verification and chunked transfers) + limitedReader := io.LimitReader(c.Request.Body, h.maxEntrySize+1) + data, err := io.ReadAll(limitedReader) + if err != nil { + h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to read request body") + c.Status(http.StatusInternalServerError) + return + } + + if int64(len(data)) > h.maxEntrySize { + c.Status(http.StatusRequestEntityTooLarge) + return + } + + // Verify content hash for CAS entries + if verifyHash { + computed := sha256.Sum256(data) + computedHex := hex.EncodeToString(computed[:]) + if computedHex != hash { + h.metrics.HashMismatches.Add(c.Request.Context(), 1, attrs) + h.logger.Warn(). + Str("expected", hash). + Str("computed", computedHex). + Msg("bazel CAS hash mismatch") + c.Status(http.StatusBadRequest) + return + } + } + + contentLength = int64(len(data)) + h.metrics.EntrySize.Record(c.Request.Context(), float64(contentLength), attrs) + + err = store.Put(c.Request.Context(), hash, bytes.NewReader(data), contentLength) + if err != nil { + h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry") + c.Status(http.StatusInternalServerError) + return + } + + // Bazel expects 200 OK on successful PUT (not 201 Created like Gradle) + c.Status(http.StatusOK) +} diff --git a/src/internal/handler/validation.go b/src/internal/handler/validation.go new file mode 100644 index 0000000..f1fc98a --- /dev/null +++ b/src/internal/handler/validation.go @@ -0,0 +1,14 @@ +package handler + +// isValidSHA256Hex returns true if s is a valid lowercase hex-encoded SHA-256 hash (64 characters). +func isValidSHA256Hex(s string) bool { + if len(s) != 64 { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} diff --git a/src/internal/server/server.go b/src/internal/server/server.go index e92a2f8..7edd1b3 100644 --- a/src/internal/server/server.go +++ b/src/internal/server/server.go @@ -86,12 +86,33 @@ func (s *Server) setupRoutes() { s.logger.Fatal().Err(err).Msg("Failed to initialize cache") } - // Create cache group with optional auth - cacheGroup := s.router.Group("/cache") + // Gradle cache endpoints + gradleGroup := s.router.Group("/gradle") + gradleGroup.GET("/:key", s.cacheAuth(false), cacheHandler.Get) + gradleGroup.HEAD("/:key", s.cacheAuth(false), cacheHandler.Head) + gradleGroup.PUT("/:key", s.cacheAuth(true), cacheHandler.Put) + + // Bazel HTTP remote cache endpoints + nsStorage, ok := s.storage.(storage.NamespacedStorage) + if !ok { + s.logger.Fatal().Msg("Storage backend does not support namespaces, required for Bazel cache") + } + + bazelHandler, err := handler.NewBazelHandler( + nsStorage, + s.cfg.MaxEntrySizeBytes(), + s.cfg.Cache.VerifyCASHash, + s.logger, + ) + if err != nil { + s.logger.Fatal().Err(err).Msg("Failed to initialize Bazel cache handler") + } - cacheGroup.GET("/:key", s.cacheAuth(false), cacheHandler.Get) - cacheGroup.HEAD("/:key", s.cacheAuth(false), cacheHandler.Head) - cacheGroup.PUT("/:key", s.cacheAuth(true), cacheHandler.Put) + bazelGroup := s.router.Group("/bazel") + bazelGroup.GET("/ac/:hash", s.cacheAuth(false), bazelHandler.GetAC) + bazelGroup.PUT("/ac/:hash", s.cacheAuth(true), bazelHandler.PutAC) + bazelGroup.GET("/cas/:hash", s.cacheAuth(false), bazelHandler.GetCAS) + bazelGroup.PUT("/cas/:hash", s.cacheAuth(true), bazelHandler.PutCAS) } func (s *Server) cacheAuth(requireWriter bool) gin.HandlerFunc { From 4d591d090e31cad50b220dbcef3329d9dfefc5ef Mon Sep 17 00:00:00 2001 From: = Date: Tue, 24 Mar 2026 15:32:00 +0100 Subject: [PATCH 2/5] Bump Chart version --- chart/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 98ad6c5..ca61ec8 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -5,7 +5,7 @@ description: A Gradle Build Cache server with Redis backend for Theia IDE deploy type: application # Chart version - bump for breaking changes -version: 0.3.1 +version: 0.4.0 # Application version - matches the cache server version appVersion: "0.1.0" From bd195456efc4e9217795b8f39a82e8955ca3effa Mon Sep 17 00:00:00 2001 From: = Date: Tue, 24 Mar 2026 17:02:11 +0100 Subject: [PATCH 3/5] Update Bazel put --- src/internal/handler/bazel_put.go | 137 ++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 25 deletions(-) diff --git a/src/internal/handler/bazel_put.go b/src/internal/handler/bazel_put.go index aae16ee..34c34f5 100644 --- a/src/internal/handler/bazel_put.go +++ b/src/internal/handler/bazel_put.go @@ -1,11 +1,12 @@ package handler import ( - "bytes" "crypto/sha256" "encoding/hex" + "fmt" "io" "net/http" + "os" "github.com/gin-gonic/gin" "github.com/kevingruber/gradle-cache/internal/storage" @@ -33,7 +34,7 @@ func (h *BazelHandler) put(c *gin.Context, store storage.Storage, cacheType stri attrs := metric.WithAttributes(attribute.String("cache_type", cacheType)) - // Check Content-Length for size validation + // Early rejection if Content-Length is known and too large contentLength := c.Request.ContentLength if contentLength > h.maxEntrySize { h.logger.Warn(). @@ -46,45 +47,131 @@ func (h *BazelHandler) put(c *gin.Context, store storage.Storage, cacheType stri return } - // Read the body (needed for both hash verification and chunked transfers) - limitedReader := io.LimitReader(c.Request.Body, h.maxEntrySize+1) - data, err := io.ReadAll(limitedReader) + if verifyHash { + h.putWithVerify(c, store, hash, cacheType, attrs) + } else { + h.putDirect(c, store, hash, cacheType, contentLength, attrs) + } +} + +// putDirect streams the request body to storage without hash verification. +// If Content-Length is known, streams directly. Otherwise spools to a temp file. +func (h *BazelHandler) putDirect(c *gin.Context, store storage.Storage, hash, cacheType string, contentLength int64, attrs metric.MeasurementOption) { + if contentLength >= 0 { + // Content-Length known: stream directly to storage + limited := io.LimitReader(c.Request.Body, contentLength) + h.metrics.EntrySize.Record(c.Request.Context(), float64(contentLength), attrs) + + if err := store.Put(c.Request.Context(), hash, limited, contentLength); err != nil { + h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry") + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusOK) + return + } + + // Chunked transfer: spool to temp file to determine size + size, reader, cleanup, err := h.spoolToTempFile(c.Request.Body) + if cleanup != nil { + defer cleanup() + } if err != nil { h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to read request body") c.Status(http.StatusInternalServerError) return } - - if int64(len(data)) > h.maxEntrySize { + if size > h.maxEntrySize { c.Status(http.StatusRequestEntityTooLarge) return } - // Verify content hash for CAS entries - if verifyHash { - computed := sha256.Sum256(data) - computedHex := hex.EncodeToString(computed[:]) - if computedHex != hash { - h.metrics.HashMismatches.Add(c.Request.Context(), 1, attrs) - h.logger.Warn(). - Str("expected", hash). - Str("computed", computedHex). - Msg("bazel CAS hash mismatch") - c.Status(http.StatusBadRequest) - return - } + h.metrics.EntrySize.Record(c.Request.Context(), float64(size), attrs) + + if err := store.Put(c.Request.Context(), hash, reader, size); err != nil { + h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry") + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusOK) +} + +// putWithVerify spools the upload to a temp file while computing the SHA-256 hash, +// then verifies the hash before storing. +func (h *BazelHandler) putWithVerify(c *gin.Context, store storage.Storage, hash, cacheType string, attrs metric.MeasurementOption) { + f, err := os.CreateTemp("", "bazel-cas-*") + if err != nil { + h.logger.Error().Err(err).Msg("failed to create temp file for CAS verification") + c.Status(http.StatusInternalServerError) + return } + defer os.Remove(f.Name()) + defer f.Close() - contentLength = int64(len(data)) - h.metrics.EntrySize.Record(c.Request.Context(), float64(contentLength), attrs) + hasher := sha256.New() + limited := io.LimitReader(c.Request.Body, h.maxEntrySize+1) + tee := io.TeeReader(limited, hasher) - err = store.Put(c.Request.Context(), hash, bytes.NewReader(data), contentLength) + written, err := io.Copy(f, tee) if err != nil { - h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry") + h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to read request body") c.Status(http.StatusInternalServerError) return } - // Bazel expects 200 OK on successful PUT (not 201 Created like Gradle) + if written > h.maxEntrySize { + c.Status(http.StatusRequestEntityTooLarge) + return + } + + computedHex := hex.EncodeToString(hasher.Sum(nil)) + if computedHex != hash { + h.metrics.HashMismatches.Add(c.Request.Context(), 1, attrs) + h.logger.Warn(). + Str("expected", hash). + Str("computed", computedHex). + Msg("bazel CAS hash mismatch") + c.Status(http.StatusBadRequest) + return + } + + if _, err := f.Seek(0, io.SeekStart); err != nil { + h.logger.Error().Err(err).Msg("failed to seek temp file") + c.Status(http.StatusInternalServerError) + return + } + + h.metrics.EntrySize.Record(c.Request.Context(), float64(written), attrs) + + if err := store.Put(c.Request.Context(), hash, f, written); err != nil { + h.logger.Error().Err(err).Str("hash", hash).Str("cache_type", cacheType).Msg("failed to store bazel cache entry") + c.Status(http.StatusInternalServerError) + return + } c.Status(http.StatusOK) } + +// spoolToTempFile copies from r (limited to maxEntrySize+1) into a temp file +// and returns the written size, a reader seeked to start, and a cleanup function. +func (h *BazelHandler) spoolToTempFile(r io.Reader) (int64, io.Reader, func(), error) { + f, err := os.CreateTemp("", "bazel-spool-*") + if err != nil { + return 0, nil, nil, fmt.Errorf("create temp file: %w", err) + } + cleanup := func() { + f.Close() + os.Remove(f.Name()) + } + + limited := io.LimitReader(r, h.maxEntrySize+1) + written, err := io.Copy(f, limited) + if err != nil { + return 0, nil, cleanup, fmt.Errorf("spool to temp file: %w", err) + } + + if _, err := f.Seek(0, io.SeekStart); err != nil { + return 0, nil, cleanup, fmt.Errorf("seek temp file: %w", err) + } + + return written, f, cleanup, nil +} From 9c20b908e76674d4a0f63155721a85ecc3a790d4 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 27 Mar 2026 17:17:06 +0100 Subject: [PATCH 4/5] helm improvements --- .github/workflows/chart-preview.yml | 4 +- .github/workflows/docker-build.yml | 20 +++- .github/workflows/release.yml | 6 +- chart/Chart.yaml | 11 +- chart/templates/_helpers.tpl | 7 +- chart/templates/auth-secrets.yaml | 8 +- chart/templates/configmap.yaml | 6 +- chart/templates/deployment.yaml | 23 ++-- chart/templates/redis-deployment.yaml | 14 +-- chart/templates/redis-secret.yaml | 6 +- chart/templates/redis-service.yaml | 6 +- chart/templates/reposilite-secret.yaml | 14 +++ chart/templates/reposilite-shared-config.yaml | 13 +-- chart/templates/service.yaml | 6 +- chart/values.yaml | 106 +++++++++++------- 15 files changed, 145 insertions(+), 105 deletions(-) create mode 100644 chart/templates/reposilite-secret.yaml diff --git a/.github/workflows/chart-preview.yml b/.github/workflows/chart-preview.yml index 3276a4c..e39f14b 100644 --- a/.github/workflows/chart-preview.yml +++ b/.github/workflows/chart-preview.yml @@ -39,7 +39,7 @@ jobs: - name: Package and push run: | helm package ./chart - helm push theia-shared-cache-${PREVIEW_VERSION}.tgz oci://ghcr.io/${{ env.REGISTRY_OWNER }}/charts + helm push eduide-shared-cache-${PREVIEW_VERSION}.tgz oci://ghcr.io/${{ env.REGISTRY_OWNER }}/charts - name: Comment install instructions uses: marocchino/sticky-pull-request-comment@v2 @@ -49,7 +49,7 @@ jobs: ## Chart Preview Ready ```bash - helm install test oci://ghcr.io/${{ env.REGISTRY_OWNER }}/charts/theia-shared-cache --version ${{ env.PREVIEW_VERSION }} + helm install test oci://ghcr.io/${{ env.REGISTRY_OWNER }}/charts/eduide-shared-cache --version ${{ env.PREVIEW_VERSION }} ``` _Updated: ${{ github.sha }}_ diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c8c1b16..7a87004 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -4,16 +4,16 @@ on: push: branches: - main - tags: - - 'v*' paths: - 'src/**' + - 'chart/**' - '.github/workflows/docker-build.yml' pull_request: branches: - main paths: - 'src/**' + - 'chart/**' env: REGISTRY: ghcr.io @@ -34,13 +34,22 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry - if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute version tag + id: version + run: | + BASE=$(grep '^version:' chart/Chart.yaml | awk '{print $2}') + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "tag=${BASE}-pr.${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + else + echo "tag=${BASE}" >> $GITHUB_OUTPUT + fi + - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 @@ -49,15 +58,14 @@ jobs: tags: | type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} type=sha,prefix= + type=raw,value=${{ steps.version.outputs.tag }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: ./src - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d931e17..aeffad2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,12 +41,12 @@ jobs: - name: Package and push to OCI run: | helm package ./chart - helm push theia-shared-cache-${{ steps.chart.outputs.version }}.tgz oci://ghcr.io/${{ env.REGISTRY_OWNER }}/charts + helm push eduide-shared-cache-${{ steps.chart.outputs.version }}.tgz oci://ghcr.io/${{ env.REGISTRY_OWNER }}/charts - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: theia-shared-cache-${{ steps.chart.outputs.version }} - name: theia-shared-cache ${{ steps.chart.outputs.version }} + tag_name: eduide-shared-cache-${{ steps.chart.outputs.version }} + name: eduide-shared-cache ${{ steps.chart.outputs.version }} generate_release_notes: true diff --git a/chart/Chart.yaml b/chart/Chart.yaml index ca61ec8..50792df 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -1,14 +1,11 @@ apiVersion: v2 -name: theia-shared-cache -description: A Gradle Build Cache server with Redis backend for Theia IDE deployments in Kubernetes +name: eduide-shared-cache +description: A Gradle Build Cache server with Redis backend for EduIDE deployments in Kubernetes type: application -# Chart version - bump for breaking changes -version: 0.4.0 - -# Application version - matches the cache server version -appVersion: "0.1.0" +# Bump this version on every release — also used as the Docker image tag +version: 0.5.0 dependencies: - name: reposilite diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index d2bcf97..c15c2c8 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -1,15 +1,14 @@ {{/* -Standard labels for theia-shared-cache resources +Standard labels for eduide-shared-cache resources */}} -{{- define "theia-shared-cache.labels" -}} +{{- define "eduide-shared-cache.labels" -}} app.kubernetes.io/name: {{ .Chart.Name }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} {{- end -}} -{{- define "theia-shared-cache.selectorLabels" -}} +{{- define "eduide-shared-cache.selectorLabels" -}} app.kubernetes.io/name: {{ .Chart.Name }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} diff --git a/chart/templates/auth-secrets.yaml b/chart/templates/auth-secrets.yaml index 9291768..4e74a30 100644 --- a/chart/templates/auth-secrets.yaml +++ b/chart/templates/auth-secrets.yaml @@ -1,11 +1,10 @@ {{- if and .Values.enabled .Values.auth.enabled }} -# Read-only cache credentials (for Theia IDE / students) +# Read-only cache credentials (for EduIDE / students) apiVersion: v1 kind: Secret metadata: - name: {{ .Release.Name }}-cache-reader + name: {{ .Chart.Name }}-reader labels: - app: {{ .Release.Name }} role: reader type: kubernetes.io/basic-auth data: @@ -16,9 +15,8 @@ data: apiVersion: v1 kind: Secret metadata: - name: {{ .Release.Name }}-cache-writer + name: {{ .Chart.Name }}-writer labels: - app: {{ .Release.Name }} role: writer type: kubernetes.io/basic-auth data: diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 2ca153b..184f04b 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -2,9 +2,9 @@ apiVersion: v1 kind: ConfigMap metadata: - name: {{ .Release.Name }}-config + name: {{ .Chart.Name }}-config labels: - {{- include "theia-shared-cache.labels" . | nindent 4 }} + {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: cache-server data: config.yaml: | @@ -20,7 +20,7 @@ data: {{- end}} storage: - addr: "{{ .Release.Name }}-redis:6379" + addr: "{{ .Chart.Name }}-redis:6379" cache: max_entry_size_mb: {{ .Values.cache.maxEntrySizeMB }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 6a84588..591f95d 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -2,20 +2,20 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ .Release.Name }}-cache-server + name: {{ .Chart.Name }}-server labels: - {{- include "theia-shared-cache.labels" . | nindent 4 }} + {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: cache-server spec: replicas: 1 selector: matchLabels: - {{- include "theia-shared-cache.selectorLabels" . | nindent 6 }} + {{- include "eduide-shared-cache.selectorLabels" . | nindent 6 }} app.kubernetes.io/component: cache-server template: metadata: labels: - {{- include "theia-shared-cache.selectorLabels" . | nindent 8 }} + {{- include "eduide-shared-cache.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: cache-server annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} @@ -29,15 +29,14 @@ spec: - -c - | echo "Waiting for Redis to be ready..." - until nc -z {{ .Release.Name }}-redis 6379; do + until nc -z {{ .Chart.Name }}-redis 6379; do echo "Redis not ready, waiting..." sleep 2 done echo "Redis is ready!" containers: - name: cache-server - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.Version }}" ports: - name: http containerPort: 8080 @@ -46,25 +45,25 @@ spec: - name: REDIS_PASSWORD valueFrom: secretKeyRef: - name: {{ .Release.Name }}-redis-secret + name: {{ .Chart.Name }}-redis-secret key: redis-password {{- if .Values.auth.enabled }} - name: CACHE_READER_PASSWORD valueFrom: secretKeyRef: - name: {{ .Release.Name }}-cache-reader + name: {{ .Chart.Name }}-reader key: password - name: CACHE_WRITER_PASSWORD valueFrom: secretKeyRef: - name: {{ .Release.Name }}-cache-writer + name: {{ .Chart.Name }}-writer key: password {{- end }} args: - --config - /etc/gradle-cache/config.yaml resources: - {{- toYaml .Values.resources.cacheServer | nindent 12 }} + {{- toYaml .Values.resources | nindent 12 }} livenessProbe: httpGet: path: /ping @@ -99,7 +98,7 @@ spec: volumes: - name: config configMap: - name: {{ .Release.Name }}-config + name: {{ .Chart.Name }}-config {{- if .Values.tls.enabled }} - name: tls-certs secret: diff --git a/chart/templates/redis-deployment.yaml b/chart/templates/redis-deployment.yaml index be8009a..01fd218 100644 --- a/chart/templates/redis-deployment.yaml +++ b/chart/templates/redis-deployment.yaml @@ -2,20 +2,20 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ .Release.Name }}-redis + name: {{ .Chart.Name }}-redis labels: - {{- include "theia-shared-cache.labels" . | nindent 4 }} + {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: storage spec: replicas: 1 selector: matchLabels: - {{- include "theia-shared-cache.selectorLabels" . | nindent 6 }} + {{- include "eduide-shared-cache.selectorLabels" . | nindent 6 }} app.kubernetes.io/component: storage template: metadata: labels: - {{- include "theia-shared-cache.selectorLabels" . | nindent 8 }} + {{- include "eduide-shared-cache.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: storage spec: containers: @@ -37,10 +37,10 @@ spec: - name: REDIS_PASSWORD valueFrom: secretKeyRef: - name: {{ .Release.Name }}-redis-secret + name: {{ .Chart.Name }}-redis-secret key: redis-password resources: - {{- toYaml .Values.resources.redis | nindent 12 }} + {{- toYaml .Values.redis.resources | nindent 12 }} livenessProbe: exec: command: @@ -72,7 +72,7 @@ spec: - name: REDIS_PASSWORD valueFrom: secretKeyRef: - name: {{ .Release.Name }}-redis-secret + name: {{ .Chart.Name }}-redis-secret key: redis-password resources: requests: diff --git a/chart/templates/redis-secret.yaml b/chart/templates/redis-secret.yaml index 7b62ecd..1140cd7 100644 --- a/chart/templates/redis-secret.yaml +++ b/chart/templates/redis-secret.yaml @@ -2,13 +2,13 @@ apiVersion: v1 kind: Secret metadata: - name: {{ .Release.Name }}-redis-secret + name: {{ .Chart.Name }}-redis-secret labels: - {{- include "theia-shared-cache.labels" . | nindent 4 }} + {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: storage type: Opaque data: - {{- $secretName := printf "%s-redis-secret" .Release.Name }} + {{- $secretName := printf "%s-redis-secret" .Chart.Name }} {{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }} {{- if $existingSecret }} redis-password: {{ index $existingSecret.data "redis-password" }} diff --git a/chart/templates/redis-service.yaml b/chart/templates/redis-service.yaml index b4bc20a..2cee120 100644 --- a/chart/templates/redis-service.yaml +++ b/chart/templates/redis-service.yaml @@ -2,9 +2,9 @@ apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-redis + name: {{ .Chart.Name }}-redis labels: - {{- include "theia-shared-cache.labels" . | nindent 4 }} + {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: storage spec: type: ClusterIP @@ -18,6 +18,6 @@ spec: targetPort: metrics protocol: TCP selector: - {{- include "theia-shared-cache.selectorLabels" . | nindent 4 }} + {{- include "eduide-shared-cache.selectorLabels" . | nindent 4 }} app.kubernetes.io/component: storage {{- end }} diff --git a/chart/templates/reposilite-secret.yaml b/chart/templates/reposilite-secret.yaml new file mode 100644 index 0000000..98ad545 --- /dev/null +++ b/chart/templates/reposilite-secret.yaml @@ -0,0 +1,14 @@ +{{- if .Values.reposilite.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Chart.Name }}-reposilite-secrets + labels: + {{- include "eduide-shared-cache.labels" . | nindent 4 }} + app.kubernetes.io/component: dependency-cache +type: Opaque +data: + adminToken: {{ printf "admin:%s" .Values.reposilite.adminToken | b64enc | quote }} + prometheusUsername: {{ .Values.reposilite.prometheus.username | b64enc | quote }} + prometheusPassword: {{ .Values.reposilite.prometheus.password | b64enc | quote }} +{{- end }} diff --git a/chart/templates/reposilite-shared-config.yaml b/chart/templates/reposilite-shared-config.yaml index 7fcee37..fb4e494 100644 --- a/chart/templates/reposilite-shared-config.yaml +++ b/chart/templates/reposilite-shared-config.yaml @@ -4,7 +4,7 @@ kind: ConfigMap metadata: name: reposilite-shared-config labels: - {{- include "theia-shared-cache.labels" . | nindent 4 }} + {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: dependency-cache data: configuration.shared.json: | @@ -17,18 +17,15 @@ data: "redeployment": false, "preserveSnapshots": false, "proxied": [ + {{- range $i, $url := .Values.reposilite.proxyRepositories }} + {{- if $i }},{{ end }} { - "reference": "https://repo1.maven.org/maven2", - "store": true, - "allowedGroups": [], - "allowedExtensions": [".jar", ".war", ".xml", ".pom", ".module", ".asc", ".md5", ".sha1", ".sha256", ".sha512"] - }, - { - "reference": "https://plugins.gradle.org/m2", + "reference": "{{ $url }}", "store": true, "allowedGroups": [], "allowedExtensions": [".jar", ".war", ".xml", ".pom", ".module", ".asc", ".md5", ".sha1", ".sha256", ".sha512"] } + {{- end }} ] } ] diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml index 2235bb2..8ac1a77 100644 --- a/chart/templates/service.yaml +++ b/chart/templates/service.yaml @@ -2,9 +2,9 @@ apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-cache + name: {{ .Chart.Name }} labels: - {{- include "theia-shared-cache.labels" . | nindent 4 }} + {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: cache-server spec: type: ClusterIP @@ -14,6 +14,6 @@ spec: targetPort: http protocol: TCP selector: - {{- include "theia-shared-cache.selectorLabels" . | nindent 4 }} + {{- include "eduide-shared-cache.selectorLabels" . | nindent 4 }} app.kubernetes.io/component: cache-server {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 0269a8a..52b56e2 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -1,48 +1,26 @@ -# Gradle Build Cache Configuration +# ============================================================ +# Cache Server +# ============================================================ -# Enable/disable the entire cache deployment enabled: true -# Docker image image: - repository: ghcr.io/ls1intum/theia-shared-cache/gradle-cache - tag: "main" - pullPolicy: IfNotPresent + repository: ghcr.io/eduide/eduide-shared-cache/gradle-cache + # Default is the Chart Version + tag: "" # Authentication credentials (role-based) -# reader: read-only access (for Theia IDE / students) +# reader: read-only access (for EduIDE / students) # writer: read-write access (for CI/CD, admin, cache pre-warming) auth: enabled: true reader: username: "reader" - # IMPORTANT: Change this password in production! - # optimaly from github envrionment secrets password: "changeme-reader" writer: username: "writer" - # IMPORTANT: Change this password in production! - # optimaly from github envrionment secrets password: "changeme-writer" -# Resource limits -resources: - cacheServer: - requests: - memory: "256Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "500m" - - redis: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "2Gi" - cpu: "1000m" - # Cache settings (shared by Gradle and Bazel) cache: # Maximum entry size in MB @@ -51,22 +29,60 @@ cache: verifyCASHash: true tls: - # Enable TLS for the cache server enabled: false - # TLS secret name (if TLS is enabled) + # Kubernetes secret name containing tls.crt and tls.key secretName: "" -# Reposilite - Maven/Gradle dependency proxy +resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "500m" + +# ============================================================ +# Redis (internal storage backend) +# ============================================================ + +redis: + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "2Gi" + cpu: "1000m" + +# ============================================================ +# Reposilite (Maven/Gradle dependency proxy) +# ============================================================ + reposilite: enabled: true - # Persistence for cached artifacts (stored on Ceph) + # Admin access token for Reposilite management + adminToken: "changeme" + + # Prometheus metrics endpoint credentials + # These are stored in a Kubernetes Secret for use in ServiceMonitors: + # eduide-shared-cache-reposilite-secrets (keys: prometheusUsername, prometheusPassword) + prometheus: + username: "prometheus" + password: "changeme-prometheus" + + # Maven/Gradle repositories to proxy and cache + proxyRepositories: + - "https://repo1.maven.org/maven2" + - "https://plugins.gradle.org/m2" + + # Persistence for cached artifacts persistence: enabled: true size: 20Gi storageClass: "csi-rbd-sc" - # Resource limits (Reposilite is lightweight - ~20-30MB RAM) + # Resource limits resources: requests: memory: "128Mi" @@ -75,18 +91,30 @@ reposilite: memory: "256Mi" cpu: "500m" - # JVM and Reposilite startup settings + # ------------------------------------------------------------ + # Internal: subchart wiring — not intended for user modification + # ------------------------------------------------------------ env: - name: JAVA_OPTS value: "-Xmx128M" + - name: REPOSILITE_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: eduide-shared-cache-reposilite-secrets + key: adminToken - name: REPOSILITE_OPTS - value: "--token admin:changeme --shared-configuration=/etc/reposilite/configuration.shared.json" + value: "--token $(REPOSILITE_ADMIN_TOKEN) --shared-configuration=/etc/reposilite/configuration.shared.json" - name: REPOSILITE_PROMETHEUS_USER - value: "prometheus" + valueFrom: + secretKeyRef: + name: eduide-shared-cache-reposilite-secrets + key: prometheusUsername - name: REPOSILITE_PROMETHEUS_PASSWORD - value: "prometheus" + valueFrom: + secretKeyRef: + name: eduide-shared-cache-reposilite-secrets + key: prometheusPassword - # Mount shared configuration + plugins into Reposilite pod deployment: initContainers: - name: download-prometheus-plugin From 4f4caa5663b4fe7df7900d45e36ec4b2bbc349e9 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 27 Mar 2026 17:42:53 +0100 Subject: [PATCH 5/5] add service monitor to chart --- chart/templates/reposilite-secret.yaml | 13 ++++++--- chart/templates/servicemonitor-cache.yaml | 23 ++++++++++++++++ .../templates/servicemonitor-reposilite.yaml | 27 +++++++++++++++++++ chart/values.yaml | 15 +++++++---- 4 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 chart/templates/servicemonitor-cache.yaml create mode 100644 chart/templates/servicemonitor-reposilite.yaml diff --git a/chart/templates/reposilite-secret.yaml b/chart/templates/reposilite-secret.yaml index 98ad545..90ad0c1 100644 --- a/chart/templates/reposilite-secret.yaml +++ b/chart/templates/reposilite-secret.yaml @@ -1,14 +1,21 @@ {{- if .Values.reposilite.enabled }} +{{- $secretName := printf "%s-reposilite-secrets" .Chart.Name }} +{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }} apiVersion: v1 kind: Secret metadata: - name: {{ .Chart.Name }}-reposilite-secrets + name: {{ $secretName }} labels: {{- include "eduide-shared-cache.labels" . | nindent 4 }} app.kubernetes.io/component: dependency-cache type: Opaque data: adminToken: {{ printf "admin:%s" .Values.reposilite.adminToken | b64enc | quote }} - prometheusUsername: {{ .Values.reposilite.prometheus.username | b64enc | quote }} - prometheusPassword: {{ .Values.reposilite.prometheus.password | b64enc | quote }} + {{- if $existingSecret }} + prometheusUsername: {{ index $existingSecret.data "prometheusUsername" }} + prometheusPassword: {{ index $existingSecret.data "prometheusPassword" }} + {{- else }} + prometheusUsername: {{ "prometheus" | b64enc | quote }} + prometheusPassword: {{ randAlphaNum 32 | b64enc | quote }} + {{- end }} {{- end }} diff --git a/chart/templates/servicemonitor-cache.yaml b/chart/templates/servicemonitor-cache.yaml new file mode 100644 index 0000000..be479de --- /dev/null +++ b/chart/templates/servicemonitor-cache.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.enabled .Values.monitoring.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ .Chart.Name }}-cache + namespace: {{ .Release.Namespace }} + labels: + {{- include "eduide-shared-cache.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + app.kubernetes.io/component: cache-server + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + endpoints: + - port: http + path: /metrics + interval: 15s + scheme: https + tlsConfig: + insecureSkipVerify: true +{{- end }} diff --git a/chart/templates/servicemonitor-reposilite.yaml b/chart/templates/servicemonitor-reposilite.yaml new file mode 100644 index 0000000..ca86a98 --- /dev/null +++ b/chart/templates/servicemonitor-reposilite.yaml @@ -0,0 +1,27 @@ +{{- if and .Values.reposilite.enabled .Values.monitoring.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ .Chart.Name }}-reposilite + namespace: {{ .Release.Namespace }} + labels: + {{- include "eduide-shared-cache.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + app.kubernetes.io/name: reposilite + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + endpoints: + - port: http + path: /metrics + interval: 15s + basicAuth: + username: + name: {{ .Chart.Name }}-reposilite-secrets + key: prometheusUsername + password: + name: {{ .Chart.Name }}-reposilite-secrets + key: prometheusPassword +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 52b56e2..bd4bd59 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -54,6 +54,15 @@ redis: memory: "2Gi" cpu: "1000m" +# ============================================================ +# Monitoring +# ============================================================ + +monitoring: + # Create ServiceMonitor resources for Prometheus discovery. + # Requires the Prometheus Operator to be installed in the cluster. + enabled: true + # ============================================================ # Reposilite (Maven/Gradle dependency proxy) # ============================================================ @@ -64,12 +73,8 @@ reposilite: # Admin access token for Reposilite management adminToken: "changeme" - # Prometheus metrics endpoint credentials - # These are stored in a Kubernetes Secret for use in ServiceMonitors: + # Prometheus credentials are auto-generated on first install and stored in: # eduide-shared-cache-reposilite-secrets (keys: prometheusUsername, prometheusPassword) - prometheus: - username: "prometheus" - password: "changeme-prometheus" # Maven/Gradle repositories to proxy and cache proxyRepositories: