diff --git a/.gitignore b/.gitignore index 8c1c5836a..f6ec620f9 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,7 @@ values.local.yaml deployment/chainloop/charts/**/*.tgz # Local development overrides -app/controlplane/configs/config.local.yaml +config.local.yaml devel/dex/config.dev.local.yaml devel/compose.override.yml -.claude/worktrees/ \ No newline at end of file +.claude/worktrees/ diff --git a/app/controlplane/cmd/wire.go b/app/controlplane/cmd/wire.go index c3ac9787c..baffb49bb 100644 --- a/app/controlplane/cmd/wire.go +++ b/app/controlplane/cmd/wire.go @@ -21,10 +21,13 @@ package main import ( + "time" + conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/dispatcher" "github.com/chainloop-dev/chainloop/app/controlplane/internal/server" "github.com/chainloop-dev/chainloop/app/controlplane/internal/service" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" @@ -33,9 +36,12 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" + "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/go-kratos/kratos/v2/log" + "github.com/golang-jwt/jwt/v4" "github.com/google/wire" + "github.com/nats-io/nats.go" ) func wireApp(*conf.Bootstrap, credentials.ReaderWriter, log.Logger, sdk.AvailablePlugins) (*app, func(), error) { @@ -60,6 +66,7 @@ func wireApp(*conf.Bootstrap, credentials.ReaderWriter, log.Logger, sdk.Availabl newDataConf, newPolicyProviderConfig, newNatsConnection, + cacheProviderSet, auditor.NewAuditLogPublisher, newCASServerOptions, newAuthAllowList, @@ -127,3 +134,48 @@ func newCASServerOptions(in *conf.Bootstrap_CASServer) *biz.CASServerDefaultOpts func newAuthAllowList(conf *conf.Bootstrap) *pkgConf.AllowList { return conf.Auth.GetAllowList() } + +var cacheProviderSet = wire.NewSet( + newMembershipsCache, + newClaimsCache, +) + +func newClaimsCache(conn *nats.Conn, logger log.Logger) (cache.Cache[*jwt.MapClaims], error) { + l := log.NewHelper(logger) + backend := "memory" + opts := []cache.Option{cache.WithTTL(10 * time.Second), cache.WithLogger(&kratosLogAdapter{h: l})} + if conn != nil { + backend = "nats" + opts = append(opts, cache.WithNATS(conn, "chainloop-jwt-claims")) + } + l.Infow("msg", "cache initialized", "bucket", "chainloop-jwt-claims", "backend", backend, "ttl", "10s") + return cache.New[*jwt.MapClaims](opts...) +} + +func newMembershipsCache(conn *nats.Conn, logger log.Logger) (cache.Cache[*entities.Membership], error) { + l := log.NewHelper(logger) + backend := "memory" + opts := []cache.Option{cache.WithTTL(time.Second), cache.WithLogger(&kratosLogAdapter{h: l})} + if conn != nil { + backend = "nats" + opts = append(opts, cache.WithNATS(conn, "chainloop-memberships")) + } + l.Infow("msg", "cache initialized", "bucket", "chainloop-memberships", "backend", backend, "ttl", "1s") + return cache.New[*entities.Membership](opts...) +} + +// kratosLogAdapter adapts kratos log.Helper (Debugw(...interface{})) to cache.Logger (Debugw(string, ...any)). +type kratosLogAdapter struct{ h *log.Helper } + +func (a *kratosLogAdapter) Debugw(msg string, keyvals ...any) { + a.h.Debugw(append([]any{"msg", msg}, keyvals...)...) +} +func (a *kratosLogAdapter) Infow(msg string, keyvals ...any) { + a.h.Infow(append([]any{"msg", msg}, keyvals...)...) +} +func (a *kratosLogAdapter) Warnw(msg string, keyvals ...any) { + a.h.Warnw(append([]any{"msg", msg}, keyvals...)...) +} +func (a *kratosLogAdapter) Errorw(msg string, keyvals ...any) { + a.h.Errorw(append([]any{"msg", msg}, keyvals...)...) +} diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 843d62557..8182c9b6a 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -11,6 +11,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/dispatcher" "github.com/chainloop-dev/chainloop/app/controlplane/internal/server" "github.com/chainloop-dev/chainloop/app/controlplane/internal/service" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" @@ -19,8 +20,13 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/pkg/blobmanager/loader" + "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/credentials" "github.com/go-kratos/kratos/v2/log" + "github.com/golang-jwt/jwt/v4" + "github.com/google/wire" + "github.com/nats-io/nats.go" + "time" ) import ( @@ -135,6 +141,16 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l } workflowContractUseCase := biz.NewWorkflowContractUseCase(workflowContractRepo, registry, auditorUseCase, logger) workflowUseCase := biz.NewWorkflowUsecase(workflowRepo, projectsRepo, workflowContractUseCase, auditorUseCase, membershipUseCase, organizationRepo, logger) + cache, err := newMembershipsCache(conn, logger) + if err != nil { + cleanup() + return nil, nil, err + } + cacheCache, err := newClaimsCache(conn, logger) + if err != nil { + cleanup() + return nil, nil, err + } orgInvitationRepo := data.NewOrgInvitation(dataData, logger) orgInvitationUseCase, err := biz.NewOrgInvitationUseCase(orgInvitationRepo, membershipRepo, userRepo, auditorUseCase, groupRepo, projectsRepo, logger) if err != nil { @@ -205,6 +221,7 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l SigningUseCase: signingUseCase, UserUC: userUseCase, BootstrapConfig: bootstrap, + MembershipsCache: cache, Opts: v5, } attestationService := service.NewAttestationService(newAttestationServiceOpts) @@ -259,6 +276,8 @@ func wireApp(bootstrap *conf.Bootstrap, readerWriter credentials.ReaderWriter, l OrganizationUseCase: organizationUseCase, WorkflowUseCase: workflowUseCase, MembershipUseCase: membershipUseCase, + MembershipsCache: cache, + ClaimsCache: cacheCache, WorkflowSvc: workflowService, AuthSvc: authService, RobotAccountSvc: robotAccountService, @@ -375,3 +394,51 @@ func newCASServerOptions(in *conf.Bootstrap_CASServer) *biz.CASServerDefaultOpts func newAuthAllowList(conf2 *conf.Bootstrap) *v1.AllowList { return conf2.Auth.GetAllowList() } + +var cacheProviderSet = wire.NewSet( + newMembershipsCache, + newClaimsCache, +) + +func newClaimsCache(conn *nats.Conn, logger log.Logger) (cache.Cache[*jwt.MapClaims], error) { + l := log.NewHelper(logger) + backend := "memory" + opts := []cache.Option{cache.WithTTL(10 * time.Second), cache.WithLogger(&kratosLogAdapter{h: l})} + if conn != nil { + backend = "nats" + opts = append(opts, cache.WithNATS(conn, "chainloop-jwt-claims")) + } + l.Infow("msg", "cache initialized", "bucket", "chainloop-jwt-claims", "backend", backend, "ttl", "10s") + return cache.New[*jwt.MapClaims](opts...) +} + +func newMembershipsCache(conn *nats.Conn, logger log.Logger) (cache.Cache[*entities.Membership], error) { + l := log.NewHelper(logger) + backend := "memory" + opts := []cache.Option{cache.WithTTL(time.Second), cache.WithLogger(&kratosLogAdapter{h: l})} + if conn != nil { + backend = "nats" + opts = append(opts, cache.WithNATS(conn, "chainloop-memberships")) + } + l.Infow("msg", "cache initialized", "bucket", "chainloop-memberships", "backend", backend, "ttl", "1s") + return cache.New[*entities.Membership](opts...) +} + +// kratosLogAdapter adapts kratos log.Helper (Debugw(...interface{})) to cache.Logger (Debugw(string, ...any)). +type kratosLogAdapter struct{ h *log.Helper } + +func (a *kratosLogAdapter) Debugw(msg string, keyvals ...any) { + a.h.Debugw(append([]any{"msg", msg}, keyvals...)...) +} + +func (a *kratosLogAdapter) Infow(msg string, keyvals ...any) { + a.h.Infow(append([]any{"msg", msg}, keyvals...)...) +} + +func (a *kratosLogAdapter) Warnw(msg string, keyvals ...any) { + a.h.Warnw(append([]any{"msg", msg}, keyvals...)...) +} + +func (a *kratosLogAdapter) Errorw(msg string, keyvals ...any) { + a.h.Errorw(append([]any{"msg", msg}, keyvals...)...) +} diff --git a/app/controlplane/internal/server/grpc.go b/app/controlplane/internal/server/grpc.go index 70dcb936b..cebc15699 100644 --- a/app/controlplane/internal/server/grpc.go +++ b/app/controlplane/internal/server/grpc.go @@ -25,9 +25,11 @@ import ( conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/sentrycontext" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/attjwtmiddleware" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" authzMiddleware "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz/middleware" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/user" + "github.com/chainloop-dev/chainloop/pkg/cache" "buf.build/go/protovalidate" "github.com/chainloop-dev/chainloop/app/controlplane/internal/service" @@ -61,6 +63,8 @@ type Opts struct { OrganizationUseCase *biz.OrganizationUseCase WorkflowUseCase *biz.WorkflowUseCase MembershipUseCase *biz.MembershipUseCase + MembershipsCache cache.Cache[*entities.Membership] + ClaimsCache cache.Cache[*jwt.MapClaims] // Services WorkflowSvc *service.WorkflowService AuthSvc *service.AuthService @@ -203,7 +207,7 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { // 2.c - Set its user usercontext.WithCurrentUserMiddleware(opts.UserUseCase, logHelper), // Store all memberships in the context - usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase), + usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase, opts.MembershipsCache), selector.Server( // 2.d- Set its organization usercontext.WithCurrentOrganizationMiddleware(opts.UserUseCase, opts.OrganizationUseCase, logHelper), @@ -235,6 +239,7 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { attjwtmiddleware.NewUserTokenProvider(opts.AuthConfig.GeneratedJwsHmacSecret), // Delegated Federated provider attjwtmiddleware.WithFederatedProvider(opts.FederatedConfig), + attjwtmiddleware.WithClaimsCache(opts.ClaimsCache), ), // 2.a - Set its workflow and organization in the context usercontext.WithAttestationContextFromRobotAccount(opts.RobotAccountUseCase, opts.OrganizationUseCase, logHelper), @@ -245,7 +250,7 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { // 2.d - Set its robot account from federated delegation usercontext.WithAttestationContextFromFederatedInfo(opts.OrganizationUseCase, logHelper), // Store all memberships in the context - usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase), + usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase, opts.MembershipsCache), // 3 - Update API Token last usage usercontext.WithAPITokenUsageUpdater(opts.APITokenUseCase, logHelper), // 4 - Validate the CAS Backend is fully configured and valid diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index d56037d5c..815cc0eec 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import ( casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" "github.com/chainloop-dev/chainloop/pkg/attestation" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" + "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/credentials" errors "github.com/go-kratos/kratos/v2/errors" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -62,6 +63,7 @@ type AttestationService struct { signingUseCase *biz.SigningUseCase userUseCase *biz.UserUseCase bootstrapConfig *conf.Bootstrap + membershipsCache cache.Cache[*entities.Membership] } type NewAttestationServiceOpts struct { @@ -83,6 +85,7 @@ type NewAttestationServiceOpts struct { SigningUseCase *biz.SigningUseCase UserUC *biz.UserUseCase BootstrapConfig *conf.Bootstrap + MembershipsCache cache.Cache[*entities.Membership] Opts []NewOpt } @@ -107,6 +110,7 @@ func NewAttestationService(opts *NewAttestationServiceOpts) *AttestationService signingUseCase: opts.SigningUseCase, userUseCase: opts.UserUC, bootstrapConfig: opts.BootstrapConfig, + membershipsCache: opts.MembershipsCache, } } @@ -797,7 +801,9 @@ func (s *AttestationService) FindOrCreateWorkflow(ctx context.Context, req *cpAP } // reset RBAC cache, since we might have created a new project - usercontext.ResetMembershipsCache() + if s.membershipsCache != nil { + _ = s.membershipsCache.Purge(ctx) + } return &cpAPI.FindOrCreateWorkflowResponse{Result: bizWorkflowToPb(wf)}, nil } diff --git a/app/controlplane/internal/usercontext/attjwtmiddleware/attmiddleware.go b/app/controlplane/internal/usercontext/attjwtmiddleware/attmiddleware.go index c24297ad1..3d843fac0 100644 --- a/app/controlplane/internal/usercontext/attjwtmiddleware/attmiddleware.go +++ b/app/controlplane/internal/usercontext/attjwtmiddleware/attmiddleware.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,7 +24,6 @@ import ( "io" "net/http" "strings" - "time" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" @@ -32,11 +31,11 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/apitoken" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/robotaccount" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/user" + "github.com/chainloop-dev/chainloop/pkg/cache" errorsAPI "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" "github.com/go-kratos/kratos/v2/middleware" "github.com/go-kratos/kratos/v2/transport" - "github.com/hashicorp/golang-lru/v2/expirable" "github.com/golang-jwt/jwt/v4" ) @@ -199,6 +198,12 @@ func WithVerifyAudienceFunc(f VerifyAudienceFunc) TokenProviderOption { type options struct { tokenProviders []providerOption federatedAuthURL string + claimsCache cache.Cache[*jwt.MapClaims] +} + +// WithClaimsCache sets an external cache for federated JWT claims. +func WithClaimsCache(c cache.Cache[*jwt.MapClaims]) JWTOption { + return func(o *options) { o.claimsCache = c } } func withTokenProvider(providerKey string, opts ...TokenProviderOption) JWTOption { @@ -251,9 +256,6 @@ func WithJWTMulti(l log.Logger, opts ...JWTOption) middleware.Middleware { logger.Infof("federated authentication enabled, using URL: %s", o.federatedAuthURL) } - // claims cache with 10s TTL and unlimited keys - claimsCache := expirable.NewLRU[string, *jwt.MapClaims](0, nil, time.Second*10) - return func(handler middleware.Handler) middleware.Handler { return func(ctx context.Context, req interface{}) (interface{}, error) { if header, ok := transport.FromServerContext(ctx); ok { @@ -286,7 +288,7 @@ func WithJWTMulti(l log.Logger, opts ...JWTOption) middleware.Middleware { } logger.Infof("calling federated provider, orgName: %s", orgName) - claims, err := callFederatedProvider(o.federatedAuthURL, jwtToken, orgName, claimsCache) + claims, err := callFederatedProvider(ctx, o.federatedAuthURL, jwtToken, orgName, o.claimsCache) if err != nil { // if we receive an error from upstream we want to expose it to the user, for example if the federated provider // is saying that the token is invalid @@ -333,9 +335,9 @@ func WithJWTMulti(l log.Logger, opts ...JWTOption) middleware.Middleware { // callFederatedProvider calls the federated provider to verify the token // it returns the claims of the token if the token is valid and verified -func callFederatedProvider(verifyURL string, jwtToken, orgName string, cache *expirable.LRU[string, *jwt.MapClaims]) (*jwt.MapClaims, error) { +func callFederatedProvider(ctx context.Context, verifyURL string, jwtToken, orgName string, claimsCache cache.Cache[*jwt.MapClaims]) (*jwt.MapClaims, error) { cacheKey := fmt.Sprintf("%s:%s", jwtToken, orgName) - if claims, ok := cache.Get(cacheKey); ok { + if claims, ok, _ := claimsCache.Get(ctx, cacheKey); ok { return claims, nil } @@ -391,7 +393,7 @@ func callFederatedProvider(verifyURL string, jwtToken, orgName string, cache *ex "orgName": response.OrgName, } - cache.Add(cacheKey, claims) + _ = claimsCache.Set(ctx, cacheKey, claims) return claims, nil } diff --git a/app/controlplane/internal/usercontext/currentorganization_middleware.go b/app/controlplane/internal/usercontext/currentorganization_middleware.go index 754eb427d..2da646949 100644 --- a/app/controlplane/internal/usercontext/currentorganization_middleware.go +++ b/app/controlplane/internal/usercontext/currentorganization_middleware.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,23 +21,19 @@ import ( "errors" "fmt" "slices" - "time" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" + "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/go-kratos/kratos/v2/log" "github.com/go-kratos/kratos/v2/middleware" "github.com/google/uuid" - "github.com/hashicorp/golang-lru/v2/expirable" ) -// membershipsCache caches user memberships to save some database queries during intensive sessions -var membershipsCache = expirable.NewLRU[string, *entities.Membership](0, nil, time.Second*1) - -func WithCurrentMembershipsMiddleware(membershipUC biz.MembershipsRBAC) middleware.Middleware { +func WithCurrentMembershipsMiddleware(membershipUC biz.MembershipsRBAC, membershipsCache cache.Cache[*entities.Membership]) middleware.Middleware { return func(handler middleware.Handler) middleware.Handler { return func(ctx context.Context, req interface{}) (interface{}, error) { // Get the current user and return if not found, meaning we are probably coming from an API Token @@ -48,7 +44,7 @@ func WithCurrentMembershipsMiddleware(membershipUC biz.MembershipsRBAC) middlewa var err error // Let's store all memberships in the context. - ctx, err = setCurrentMembershipsForUser(ctx, u, membershipUC) + ctx, err = setCurrentMembershipsForUser(ctx, u, membershipUC, membershipsCache) if err != nil { return nil, fmt.Errorf("error setting current org membership: %w", err) } @@ -107,11 +103,14 @@ func WithCurrentOrganizationMiddleware(userUseCase biz.UserOrgFinder, orgUC *biz } // setCurrentMembershipsForUser retrieves all user memberships for RBAC -func setCurrentMembershipsForUser(ctx context.Context, u *entities.User, membershipUC biz.MembershipsRBAC) (context.Context, error) { - var membership *entities.Membership - var ok bool +func setCurrentMembershipsForUser(ctx context.Context, u *entities.User, membershipUC biz.MembershipsRBAC, membershipsCache cache.Cache[*entities.Membership]) (context.Context, error) { + membership, ok, err := membershipsCache.Get(ctx, u.ID) + if err != nil { + log.Warnf("memberships cache read failed, falling through to DB: %v", err) + ok = false + } - if membership, ok = membershipsCache.Get(u.ID); !ok { + if !ok { uid, err := uuid.Parse(u.ID) if err != nil { return nil, err @@ -133,16 +132,14 @@ func setCurrentMembershipsForUser(ctx context.Context, u *entities.User, members } membership = &entities.Membership{UserID: uuid.MustParse(u.ID), Resources: resourceMemberships} - membershipsCache.Add(u.ID, membership) + if err := membershipsCache.Set(ctx, u.ID, membership); err != nil { + log.Warnf("memberships cache write failed: %v", err) + } } return entities.WithMembership(ctx, membership), nil } -func ResetMembershipsCache() { - membershipsCache.Purge() -} - func setCurrentMembershipFromOrgName(ctx context.Context, user *entities.User, orgName string, userUC biz.UserOrgFinder, orgUC *biz.OrganizationUseCase) (context.Context, error) { membership, err := userUC.MembershipInOrg(ctx, user.ID, orgName) if err != nil && !biz.IsNotFound(err) { diff --git a/go.mod b/go.mod index d1159f0d9..2c2a4590c 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( go.uber.org/zap v1.27.1 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 golang.org/x/oauth2 v0.34.0 - golang.org/x/term v0.38.0 + golang.org/x/term v0.41.0 google.golang.org/api v0.260.0 google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.79.3 @@ -81,7 +81,8 @@ require ( github.com/invopop/jsonschema v0.13.0 github.com/jackc/pgx/v5 v5.7.5 github.com/muesli/reflow v0.3.0 - github.com/nats-io/nats.go v1.34.0 + github.com/nats-io/nats-server/v2 v2.12.6 + github.com/nats-io/nats.go v1.49.0 github.com/open-policy-agent/opa v1.12.1 github.com/openvex/go-vex v0.2.5 github.com/posthog/posthog-go v0.0.0-20240327112532-87b23fe11103 @@ -131,6 +132,7 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/anchore/go-struct-converter v0.0.0-20230627203149-c72ef8859ca9 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect @@ -202,6 +204,7 @@ require ( github.com/google/cel-go v0.26.1 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-github/v73 v73.0.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/renameio/v2 v2.0.0 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/h2non/filetype v1.1.3 // indirect @@ -229,6 +232,7 @@ require ( github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/mholt/archives v0.1.5 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minlz v1.0.1 // indirect github.com/minio/sha256-simd v1.0.1 // indirect @@ -240,7 +244,8 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/muesli/termenv v0.15.1 // indirect github.com/natefinch/atomic v1.0.1 // indirect - github.com/nats-io/nkeys v0.4.7 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect @@ -391,7 +396,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -439,14 +444,14 @@ require ( go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.46.0 - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/crypto v0.49.0 + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.1 // indirect diff --git a/go.sum b/go.sum index ccf987cf3..e3fe506eb 100644 --- a/go.sum +++ b/go.sum @@ -155,6 +155,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= @@ -641,6 +643,8 @@ github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -824,8 +828,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= @@ -910,6 +914,8 @@ github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.63 h1:GbZ2oCvaUdgT5640WJOpyDhhDxvknAJU2/T3yurwcbQ= @@ -980,14 +986,18 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats-server/v2 v2.12.6 h1:Egbx9Vl7Ch8wTtpXPGqbehkZ+IncKqShUxvrt1+Enc8= +github.com/nats-io/nats-server/v2 v2.12.6/go.mod h1:4HPlrvtmSO3yd7KcElDNMx9kv5EBJBnJJzQPptXlheo= github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= -github.com/nats-io/nats.go v1.34.0 h1:fnxnPCNiwIG5w08rlMcEKTUw4AV/nKyGCOJE8TdhSPk= -github.com/nats-io/nats.go v1.34.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= -github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= -github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -1497,8 +1507,8 @@ golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIi golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1538,8 +1548,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1590,8 +1600,8 @@ golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1612,8 +1622,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1688,8 +1698,9 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1704,8 +1715,8 @@ golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1723,14 +1734,14 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1775,8 +1786,8 @@ golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 000000000..40a33624f --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,119 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + "errors" + "time" + + "github.com/nats-io/nats.go" +) + +// Cache is a generic cache interface with TTL-based expiration. +// TTL is configured at construction time, not per-operation. +type Cache[T any] interface { + Get(ctx context.Context, key string) (T, bool, error) + Set(ctx context.Context, key string, value T) error + Delete(ctx context.Context, key string) error + Purge(ctx context.Context) error +} + +// Logger is a structured logging interface compatible with Kratos log.Helper +// but does not import it. +type Logger interface { + Debugw(msg string, keyvals ...any) + Warnw(msg string, keyvals ...any) + Infow(msg string, keyvals ...any) + Errorw(msg string, keyvals ...any) +} + +// defaultMaxSize is a sensible upper bound on in-memory cache entries +// to prevent unbounded growth. 0 means no LRU eviction (TTL-only). +const defaultMaxSize = 1000 + +type config struct { + ttl time.Duration + maxSize int + logger Logger + natsConn *nats.Conn + bucketName string + reconnCh <-chan struct{} +} + +// Option configures cache construction. +type Option func(*config) + +// WithTTL sets the expiration duration for cache entries. Required. +func WithTTL(d time.Duration) Option { + return func(c *config) { c.ttl = d } +} + +// WithLogger sets a structured logger for cache operations. +func WithLogger(l Logger) Option { + return func(c *config) { c.logger = l } +} + +// WithNATS enables the NATS JetStream KV backend. +func WithNATS(conn *nats.Conn, bucketName string) Option { + return func(c *config) { + c.natsConn = conn + c.bucketName = bucketName + } +} + +// WithReconnect provides a channel that signals NATS reconnection events. +func WithReconnect(ch <-chan struct{}) Option { + return func(c *config) { c.reconnCh = ch } +} + +// New creates a new Cache[T]. If NATS options are provided and the connection +// is non-nil, a NATS KV backend is returned. Otherwise an in-memory LRU is used. +func New[T any](opts ...Option) (Cache[T], error) { + cfg := &config{} + for _, o := range opts { + o(cfg) + } + + if cfg.ttl <= 0 { + return nil, errors.New("cache: TTL must be greater than 0") + } + + if cfg.logger == nil { + cfg.logger = nopLogger{} + } + + if cfg.natsConn != nil { + if cfg.bucketName == "" { + return nil, errors.New("cache: bucket name is required when NATS backend is enabled") + } + return newNATSKV[T](cfg) + } + + if cfg.maxSize == 0 { + cfg.maxSize = defaultMaxSize + } + + return newMemoryCache[T](cfg), nil +} + +// nopLogger is a no-op implementation of Logger. +type nopLogger struct{} + +func (nopLogger) Debugw(_ string, _ ...any) {} +func (nopLogger) Warnw(_ string, _ ...any) {} +func (nopLogger) Infow(_ string, _ ...any) {} +func (nopLogger) Errorw(_ string, _ ...any) {} diff --git a/pkg/cache/memory.go b/pkg/cache/memory.go new file mode 100644 index 000000000..775b7943b --- /dev/null +++ b/pkg/cache/memory.go @@ -0,0 +1,59 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + + "github.com/hashicorp/golang-lru/v2/expirable" +) + +type memoryCache[T any] struct { + lru *expirable.LRU[string, T] + logger Logger +} + +func newMemoryCache[T any](cfg *config) *memoryCache[T] { + cfg.logger.Infow("cache: using in-memory LRU backend", "ttl", cfg.ttl, "maxSize", cfg.maxSize) + return &memoryCache[T]{ + lru: expirable.NewLRU[string, T](cfg.maxSize, nil, cfg.ttl), + logger: cfg.logger, + } +} + +func (m *memoryCache[T]) Get(_ context.Context, key string) (T, bool, error) { + val, ok := m.lru.Get(key) + m.logger.Debugw("cache get", "key", key, "hit", ok, "backend", "memory") + return val, ok, nil +} + +func (m *memoryCache[T]) Set(_ context.Context, key string, value T) error { + m.lru.Add(key, value) + m.logger.Debugw("cache set", "key", key, "backend", "memory") + return nil +} + +func (m *memoryCache[T]) Delete(_ context.Context, key string) error { + m.lru.Remove(key) + m.logger.Debugw("cache delete", "key", key, "backend", "memory") + return nil +} + +func (m *memoryCache[T]) Purge(_ context.Context) error { + m.lru.Purge() + m.logger.Debugw("cache purge", "backend", "memory") + return nil +} diff --git a/pkg/cache/memory_test.go b/pkg/cache/memory_test.go new file mode 100644 index 000000000..80abf6e74 --- /dev/null +++ b/pkg/cache/memory_test.go @@ -0,0 +1,107 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMemoryCache_GetSetDelete(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T, c Cache[string]) + }{ + { + name: "get from empty cache returns miss", + run: func(t *testing.T, c Cache[string]) { + _, ok, err := c.Get(context.Background(), "missing") + require.NoError(t, err) + assert.False(t, ok) + }, + }, + { + name: "set then get returns value", + run: func(t *testing.T, c Cache[string]) { + ctx := context.Background() + require.NoError(t, c.Set(ctx, "key1", "value1")) + val, ok, err := c.Get(ctx, "key1") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "value1", val) + }, + }, + { + name: "delete removes entry", + run: func(t *testing.T, c Cache[string]) { + ctx := context.Background() + require.NoError(t, c.Set(ctx, "key1", "value1")) + require.NoError(t, c.Delete(ctx, "key1")) + _, ok, err := c.Get(ctx, "key1") + require.NoError(t, err) + assert.False(t, ok) + }, + }, + { + name: "purge removes all entries", + run: func(t *testing.T, c Cache[string]) { + ctx := context.Background() + require.NoError(t, c.Set(ctx, "k1", "v1")) + require.NoError(t, c.Set(ctx, "k2", "v2")) + require.NoError(t, c.Purge(ctx)) + _, ok1, _ := c.Get(ctx, "k1") + _, ok2, _ := c.Get(ctx, "k2") + assert.False(t, ok1) + assert.False(t, ok2) + }, + }, + { + name: "TTL expiration", + run: func(t *testing.T, c Cache[string]) { + ctx := context.Background() + require.NoError(t, c.Set(ctx, "ephemeral", "gone-soon")) + time.Sleep(100 * time.Millisecond) + _, ok, err := c.Get(ctx, "ephemeral") + require.NoError(t, err) + assert.False(t, ok) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := New[string](WithTTL(50 * time.Millisecond)) + require.NoError(t, err) + tt.run(t, c) + }) + } +} + +func TestNew_DefaultsToMemory(t *testing.T) { + c, err := New[string](WithTTL(time.Second)) + require.NoError(t, err) + _, ok := c.(*memoryCache[string]) + assert.True(t, ok, "expected memoryCache when no NATS connection provided") +} + +func TestNew_RequiresTTL(t *testing.T) { + _, err := New[string]() + require.Error(t, err) +} diff --git a/pkg/cache/natskv.go b/pkg/cache/natskv.go new file mode 100644 index 000000000..109972db3 --- /dev/null +++ b/pkg/cache/natskv.go @@ -0,0 +1,195 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "sync" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +type natsKVCache[T any] struct { + mu sync.RWMutex + kv jetstream.KeyValue + logger Logger + conn *nats.Conn + bucket string + cfg *config +} + +func newNATSKV[T any](cfg *config) (*natsKVCache[T], error) { + c := &natsKVCache[T]{ + logger: cfg.logger, + conn: cfg.natsConn, + bucket: cfg.bucketName, + cfg: cfg, + } + + if err := c.initBucket(); err != nil { + return nil, err + } + + if cfg.reconnCh != nil { + go c.watchReconnect(cfg.reconnCh) + } + + cfg.logger.Infow("cache: using NATS KV backend", "bucket", cfg.bucketName, "ttl", cfg.ttl) + return c, nil +} + +func (c *natsKVCache[T]) initBucket() error { + js, err := jetstream.New(c.conn) + if err != nil { + return err + } + + kv, err := js.CreateOrUpdateKeyValue(context.Background(), jetstream.KeyValueConfig{ + Bucket: c.bucket, + TTL: c.cfg.ttl, + Storage: jetstream.MemoryStorage, + }) + if err != nil { + return err + } + + c.mu.Lock() + c.kv = kv + c.mu.Unlock() + return nil +} + +func (c *natsKVCache[T]) watchReconnect(ch <-chan struct{}) { + for range ch { + c.logger.Infow("cache: NATS reconnected, reinitializing bucket", "bucket", c.bucket) + if err := c.initBucket(); err != nil { + c.logger.Warnw("cache: failed to reinitialize bucket after reconnect", "bucket", c.bucket, "error", err) + } + } +} + +// sanitizeKey encodes the key for NATS KV compatibility. +// NATS KV keys only allow alphanumeric, '.', '-', '_', '/'. +// We use base64url encoding to avoid collisions from character replacement. +func sanitizeKey(key string) string { + return base64.RawURLEncoding.EncodeToString([]byte(key)) +} + +func (c *natsKVCache[T]) getKV() jetstream.KeyValue { + c.mu.RLock() + defer c.mu.RUnlock() + return c.kv +} + +// All operations degrade gracefully: if the KV handle is nil or a NATS +// operation fails, the method returns a cache miss / no-op instead of an error. +// This keeps the cache fail-open so callers fall through to the source of truth. + +func (c *natsKVCache[T]) Get(ctx context.Context, key string) (T, bool, error) { + var zero T + kv := c.getKV() + if kv == nil { + c.logger.Warnw("cache get: KV handle is nil, returning miss", "key", key, "backend", "nats") + return zero, false, nil + } + + sKey := sanitizeKey(key) + entry, err := kv.Get(ctx, sKey) + if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + c.logger.Debugw("cache get", "key", key, "hit", false, "backend", "nats") + return zero, false, nil + } + c.logger.Warnw("cache get error", "key", key, "error", err, "backend", "nats") + return zero, false, nil + } + + var val T + if err := json.Unmarshal(entry.Value(), &val); err != nil { + c.logger.Warnw("cache get: unmarshal failed, deleting corrupted entry", "key", key, "error", err, "backend", "nats") + _ = kv.Delete(ctx, sKey) + return zero, false, nil + } + + c.logger.Debugw("cache get", "key", key, "hit", true, "backend", "nats") + return val, true, nil +} + +func (c *natsKVCache[T]) Set(ctx context.Context, key string, value T) error { + kv := c.getKV() + if kv == nil { + c.logger.Warnw("cache set: KV handle is nil, skipping", "key", key, "backend", "nats") + return nil + } + + data, err := json.Marshal(value) + if err != nil { + return err + } + + if _, err := kv.Put(ctx, sanitizeKey(key), data); err != nil { + c.logger.Warnw("cache set error", "key", key, "error", err, "backend", "nats") + return nil + } + + c.logger.Debugw("cache set", "key", key, "backend", "nats") + return nil +} + +func (c *natsKVCache[T]) Delete(ctx context.Context, key string) error { + kv := c.getKV() + if kv == nil { + return nil + } + + if err := kv.Delete(ctx, sanitizeKey(key)); err != nil { + if !errors.Is(err, jetstream.ErrKeyNotFound) { + c.logger.Warnw("cache delete error", "key", key, "error", err, "backend", "nats") + } + } + + c.logger.Debugw("cache delete", "key", key, "backend", "nats") + return nil +} + +func (c *natsKVCache[T]) Purge(ctx context.Context) error { + kv := c.getKV() + if kv == nil { + return nil + } + + keys, err := kv.Keys(ctx) + if err != nil { + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil + } + c.logger.Warnw("cache purge: failed to list keys", "error", err, "backend", "nats") + return nil + } + + for _, k := range keys { + if err := kv.Purge(ctx, k); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + c.logger.Warnw("cache purge: failed to purge key", "key", k, "error", err, "backend", "nats") + } + } + + c.logger.Debugw("cache purge", "backend", "nats") + return nil +} diff --git a/pkg/cache/natskv_test.go b/pkg/cache/natskv_test.go new file mode 100644 index 000000000..d5fa4fc2a --- /dev/null +++ b/pkg/cache/natskv_test.go @@ -0,0 +1,228 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + "strings" + "testing" + "time" + + natsserver "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func startEmbeddedNATS(t *testing.T) *nats.Conn { + t.Helper() + opts := &natsserver.Options{ + Host: "127.0.0.1", + Port: -1, + JetStream: true, + StoreDir: t.TempDir(), + } + ns, err := natsserver.NewServer(opts) + require.NoError(t, err) + ns.Start() + t.Cleanup(ns.Shutdown) + + require.True(t, ns.ReadyForConnections(5*time.Second)) + nc, err := nats.Connect(ns.ClientURL()) + require.NoError(t, err) + t.Cleanup(nc.Close) + return nc +} + +// sanitizeBucketName makes test names safe for NATS bucket names (alphanumeric, dash, underscore only). +func sanitizeBucketName(name string) string { + r := strings.NewReplacer("/", "-", " ", "-", "=", "-") + return r.Replace(name) +} + +type testStruct struct { + Name string `json:"name"` + Value int `json:"value"` +} + +func TestNATSKV_GetSetDelete(t *testing.T) { + nc := startEmbeddedNATS(t) + + tests := []struct { + name string + run func(t *testing.T, c Cache[testStruct]) + }{ + { + name: "get from empty cache returns miss", + run: func(t *testing.T, c Cache[testStruct]) { + _, ok, err := c.Get(context.Background(), "missing") + require.NoError(t, err) + assert.False(t, ok) + }, + }, + { + name: "set then get returns value", + run: func(t *testing.T, c Cache[testStruct]) { + ctx := context.Background() + v := testStruct{Name: "test", Value: 42} + require.NoError(t, c.Set(ctx, "key1", v)) + got, ok, err := c.Get(ctx, "key1") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, v, got) + }, + }, + { + name: "delete removes entry", + run: func(t *testing.T, c Cache[testStruct]) { + ctx := context.Background() + require.NoError(t, c.Set(ctx, "key1", testStruct{Name: "x", Value: 1})) + require.NoError(t, c.Delete(ctx, "key1")) + _, ok, err := c.Get(ctx, "key1") + require.NoError(t, err) + assert.False(t, ok) + }, + }, + { + name: "purge removes all entries", + run: func(t *testing.T, c Cache[testStruct]) { + ctx := context.Background() + require.NoError(t, c.Set(ctx, "k1", testStruct{Name: "a", Value: 1})) + require.NoError(t, c.Set(ctx, "k2", testStruct{Name: "b", Value: 2})) + require.NoError(t, c.Purge(ctx)) + _, ok1, err := c.Get(ctx, "k1") + require.NoError(t, err) + _, ok2, err := c.Get(ctx, "k2") + require.NoError(t, err) + assert.False(t, ok1) + assert.False(t, ok2) + }, + }, + { + name: "key sanitization encodes special characters", + run: func(t *testing.T, c Cache[testStruct]) { + ctx := context.Background() + v := testStruct{Name: "sanitized", Value: 99} + require.NoError(t, c.Set(ctx, "token:org:name", v)) + got, ok, err := c.Get(ctx, "token:org:name") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, v, got) + + // Verify distinct keys with similar characters don't collide + v2 := testStruct{Name: "different", Value: 100} + require.NoError(t, c.Set(ctx, "token.org.name", v2)) + got2, ok2, err := c.Get(ctx, "token.org.name") + require.NoError(t, err) + assert.True(t, ok2) + assert.Equal(t, v2, got2) + + // Original key should still have its value + got3, ok3, err := c.Get(ctx, "token:org:name") + require.NoError(t, err) + assert.True(t, ok3) + assert.Equal(t, v, got3) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := New[testStruct]( + WithTTL(5*time.Second), + WithNATS(nc, sanitizeBucketName("test-"+t.Name())), + ) + require.NoError(t, err) + tt.run(t, c) + }) + } +} + +func TestNATSKV_TTLExpiration(t *testing.T) { + nc := startEmbeddedNATS(t) + c, err := New[string]( + WithTTL(200*time.Millisecond), + WithNATS(nc, "test-ttl"), + ) + require.NoError(t, err) + + ctx := context.Background() + require.NoError(t, c.Set(ctx, "ephemeral", "gone-soon")) + _, ok, _ := c.Get(ctx, "ephemeral") + require.True(t, ok) + + time.Sleep(400 * time.Millisecond) + _, ok, err = c.Get(ctx, "ephemeral") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestNATSKV_CorruptedDataReturnsMiss(t *testing.T) { + nc := startEmbeddedNATS(t) + c, err := New[testStruct]( + WithTTL(5*time.Second), + WithNATS(nc, "test-corrupt"), + ) + require.NoError(t, err) + + // Write garbage directly to the KV bucket + nkv := c.(*natsKVCache[testStruct]) + nkv.mu.RLock() + kv := nkv.kv + nkv.mu.RUnlock() + _, err = kv.PutString(context.Background(), "corrupt-key", "not-valid-json{{{") + require.NoError(t, err) + + // Get should return a miss (and auto-delete the corrupted entry) + _, ok, err := c.Get(context.Background(), "corrupt-key") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestNATSKV_NilKVGracefulDegradation(t *testing.T) { + nc := startEmbeddedNATS(t) + c, err := New[string]( + WithTTL(time.Second), + WithNATS(nc, "test-degradation"), + ) + require.NoError(t, err) + + // Simulate nil KV handle + nkv := c.(*natsKVCache[string]) + nkv.mu.Lock() + nkv.kv = nil + nkv.mu.Unlock() + + ctx := context.Background() + _, ok, err := nkv.Get(ctx, "key") + require.NoError(t, err) + assert.False(t, ok) + + require.NoError(t, nkv.Set(ctx, "key", "val")) + require.NoError(t, nkv.Delete(ctx, "key")) + require.NoError(t, nkv.Purge(ctx)) +} + +func TestNew_WithNATSReturnsNATSBackend(t *testing.T) { + nc := startEmbeddedNATS(t) + c, err := New[string]( + WithTTL(time.Second), + WithNATS(nc, "test-backend-select"), + ) + require.NoError(t, err) + _, ok := c.(*natsKVCache[string]) + assert.True(t, ok, "expected natsKVCache when NATS connection provided") +} diff --git a/pkg/policies/group_loader.go b/pkg/policies/group_loader.go index 9f2ccb496..d7fdc8b28 100644 --- a/pkg/policies/group_loader.go +++ b/pkg/policies/group_loader.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,11 +23,10 @@ import ( "net/url" "os" "path/filepath" - "sync" - "time" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/cache" crv1 "github.com/google/go-containerregistry/pkg/v1" "golang.org/x/sync/singleflight" ) @@ -101,53 +100,44 @@ func (l *HTTPSGroupLoader) Load(_ context.Context, attachment *v1.PolicyGroupAtt // ChainloopGroupLoader loads groups referenced with chainloop://provider/name URLs type ChainloopGroupLoader struct { Client pb.AttestationServiceClient + cache cache.Cache[*groupWithReference] + flight singleflight.Group } type groupWithReference struct { - group *v1.PolicyGroup - reference *PolicyDescriptor + Group *v1.PolicyGroup `json:"group"` + Reference *PolicyDescriptor `json:"reference"` } -// remoteGroupFetchTimeout bounds gRPC calls inside singleflight to prevent -// indefinite blocking. Independent of any caller's context deadline so that -// all singleflight waiters get uniform timeout behavior. -const remoteGroupFetchTimeout = 30 * time.Second - -var ( - remoteGroupCache = make(map[string]*groupWithReference) - remoteGroupCacheMutex sync.Mutex - remoteGroupFlight singleflight.Group -) - -func NewChainloopGroupLoader(client pb.AttestationServiceClient) *ChainloopGroupLoader { - return &ChainloopGroupLoader{Client: client} +func NewChainloopGroupLoader(client pb.AttestationServiceClient, c cache.Cache[*groupWithReference]) *ChainloopGroupLoader { + return &ChainloopGroupLoader{Client: client, cache: c} } func (c *ChainloopGroupLoader) Load(ctx context.Context, attachment *v1.PolicyGroupAttachment) (*v1.PolicyGroup, *PolicyDescriptor, error) { ref := attachment.GetRef() - // Fast path: check cache under lock - remoteGroupCacheMutex.Lock() - if v, ok := remoteGroupCache[ref]; ok { - remoteGroupCacheMutex.Unlock() - return v.group, v.reference, nil + // Fast path: check cache + if cached, ok, _ := c.cache.Get(ctx, ref); ok { + return cached.Group, cached.Reference, nil } - remoteGroupCacheMutex.Unlock() // Use singleflight to coalesce concurrent fetches for the same ref. - // Use context.WithoutCancel so the winning goroutine's context cancellation - // doesn't propagate to waiters sharing the same singleflight key. - result, err, _ := remoteGroupFlight.Do(ref, func() (interface{}, error) { + // All operations inside use a fixed-timeout context independent of any + // caller's deadline so that all singleflight waiters get uniform behavior. + result, err, _ := c.flight.Do(ref, func() (interface{}, error) { + sfCtx, cancel := context.WithTimeout(context.Background(), remoteLoaderFetchTimeout) + defer cancel() + + // Double-check cache inside singleflight + if cached, ok, _ := c.cache.Get(sfCtx, ref); ok { + return cached, nil + } + if !IsProviderScheme(ref) { return nil, fmt.Errorf("invalid group reference %q", ref) } providerRef := ProviderParts(ref) - // Use a fixed timeout independent of any caller's context so that all - // singleflight waiters get uniform behavior regardless of which goroutine - // won the race. - sfCtx, cancel := context.WithTimeout(context.Background(), remoteGroupFetchTimeout) - defer cancel() resp, err := c.Client.GetPolicyGroup(sfCtx, &pb.AttestationServiceGetPolicyGroupRequest{ Provider: providerRef.Provider, @@ -171,11 +161,9 @@ func (c *ChainloopGroupLoader) Load(ctx context.Context, attachment *v1.PolicyGr } reference := policyReferenceResourceDescriptor(providerRef.Name, resp.Reference.GetUrl(), orgName, h) - cached := &groupWithReference{group: resp.GetGroup(), reference: reference} + cached := &groupWithReference{Group: resp.GetGroup(), Reference: reference} - remoteGroupCacheMutex.Lock() - remoteGroupCache[ref] = cached - remoteGroupCacheMutex.Unlock() + _ = c.cache.Set(sfCtx, ref, cached) return cached, nil }) @@ -184,5 +172,5 @@ func (c *ChainloopGroupLoader) Load(ctx context.Context, attachment *v1.PolicyGr } cached := result.(*groupWithReference) - return cached.group, cached.reference, nil + return cached.Group, cached.Reference, nil } diff --git a/pkg/policies/loader.go b/pkg/policies/loader.go index 49745baa4..3a5ee1132 100644 --- a/pkg/policies/loader.go +++ b/pkg/policies/loader.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,12 +25,12 @@ import ( "os" "path/filepath" "strings" - "sync" "time" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" + "github.com/chainloop-dev/chainloop/pkg/cache" crv1 "github.com/google/go-containerregistry/pkg/v1" "golang.org/x/sync/singleflight" "google.golang.org/protobuf/encoding/protojson" @@ -160,55 +160,49 @@ func (l *HTTPSLoader) Load(_ context.Context, attachment *v1.PolicyAttachment) ( // ChainloopLoader loads policies referenced with chainloop://provider/name URLs type ChainloopLoader struct { Client pb.AttestationServiceClient + cache cache.Cache[*policyWithReference] + flight singleflight.Group } type policyWithReference struct { - policy *v1.Policy - reference *PolicyDescriptor + Policy *v1.Policy `json:"policy"` + Reference *PolicyDescriptor `json:"reference"` } -// remotePolicyFetchTimeout bounds gRPC calls inside singleflight to prevent +// remoteLoaderFetchTimeout bounds gRPC calls inside singleflight to prevent // indefinite blocking. Independent of any caller's context deadline so that // all singleflight waiters get uniform timeout behavior. -const remotePolicyFetchTimeout = 30 * time.Second +const remoteLoaderFetchTimeout = 30 * time.Second -var ( - remotePolicyCache = make(map[string]*policyWithReference) - remotePolicyCacheMutex sync.Mutex - remotePolicyFlight singleflight.Group -) - -func NewChainloopLoader(client pb.AttestationServiceClient) *ChainloopLoader { - return &ChainloopLoader{Client: client} +func NewChainloopLoader(client pb.AttestationServiceClient, c cache.Cache[*policyWithReference]) *ChainloopLoader { + return &ChainloopLoader{Client: client, cache: c} } func (c *ChainloopLoader) Load(ctx context.Context, attachment *v1.PolicyAttachment) (*v1.Policy, *PolicyDescriptor, error) { ref := attachment.GetRef() - // Fast path: check cache under lock - remotePolicyCacheMutex.Lock() - if v, ok := remotePolicyCache[ref]; ok { - remotePolicyCacheMutex.Unlock() - return v.policy, v.reference, nil + // Fast path: check cache + if cached, ok, _ := c.cache.Get(ctx, ref); ok { + return cached.Policy, cached.Reference, nil } - remotePolicyCacheMutex.Unlock() // Use singleflight to coalesce concurrent fetches for the same ref. - // This ensures exactly one gRPC call per ref regardless of concurrency. - // Use context.WithoutCancel so the winning goroutine's context cancellation - // doesn't propagate to waiters sharing the same singleflight key. - result, err, _ := remotePolicyFlight.Do(ref, func() (interface{}, error) { + // All operations inside use a fixed-timeout context independent of any + // caller's deadline so that all singleflight waiters get uniform behavior. + result, err, _ := c.flight.Do(ref, func() (interface{}, error) { + sfCtx, cancel := context.WithTimeout(context.Background(), remoteLoaderFetchTimeout) + defer cancel() + + // Double-check cache inside singleflight + if cached, ok, _ := c.cache.Get(sfCtx, ref); ok { + return cached, nil + } + if !IsProviderScheme(ref) { return nil, fmt.Errorf("invalid policy reference %q", ref) } providerRef := ProviderParts(ref) - // Use a fixed timeout independent of any caller's context so that all - // singleflight waiters get uniform behavior regardless of which goroutine - // won the race. The caller's cancellation/deadline is intentionally not - // inherited to avoid one caller's short deadline failing the shared call. - sfCtx, cancel := context.WithTimeout(context.Background(), remotePolicyFetchTimeout) - defer cancel() resp, err := c.Client.GetPolicy(sfCtx, &pb.AttestationServiceGetPolicyRequest{ Provider: providerRef.Provider, @@ -232,11 +226,9 @@ func (c *ChainloopLoader) Load(ctx context.Context, attachment *v1.PolicyAttachm } reference := policyReferenceResourceDescriptor(providerRef.Name, resp.Reference.GetUrl(), orgName, h) - cached := &policyWithReference{policy: resp.GetPolicy(), reference: reference} + cached := &policyWithReference{Policy: resp.GetPolicy(), Reference: reference} - remotePolicyCacheMutex.Lock() - remotePolicyCache[ref] = cached - remotePolicyCacheMutex.Unlock() + _ = c.cache.Set(sfCtx, ref, cached) return cached, nil }) @@ -245,7 +237,7 @@ func (c *ChainloopLoader) Load(ctx context.Context, attachment *v1.PolicyAttachm } cached := result.(*policyWithReference) - return cached.policy, cached.reference, nil + return cached.Policy, cached.Reference, nil } func unmarshallResource(raw []byte, ref string, digest string, dest proto.Message) (*PolicyDescriptor, error) { diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 2000f62b5..f34e182b9 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -26,8 +26,11 @@ import ( "slices" "strings" + "time" + "buf.build/go/protovalidate" v13 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/templates" intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" @@ -90,6 +93,8 @@ type PolicyVerifier struct { enablePrint bool evalPhase EvalPhase maxConcurrency int + policyCache cache.Cache[*policyWithReference] + groupCache cache.Cache[*groupWithReference] } var _ Verifier = (*PolicyVerifier)(nil) @@ -102,6 +107,8 @@ type PolicyVerifierOptions struct { GRPCConn *grpc.ClientConn EvalPhase EvalPhase MaxConcurrency int + PolicyCache cache.Cache[*policyWithReference] + GroupCache cache.Cache[*groupWithReference] } type PolicyVerifierOption func(*PolicyVerifierOptions) @@ -148,6 +155,20 @@ func WithMaxConcurrency(n int) PolicyVerifierOption { } } +func WithPolicyCache(c cache.Cache[*policyWithReference]) PolicyVerifierOption { + return func(o *PolicyVerifierOptions) { + o.PolicyCache = c + } +} + +func WithGroupCache(c cache.Cache[*groupWithReference]) PolicyVerifierOption { + return func(o *PolicyVerifierOptions) { + o.GroupCache = c + } +} + +const defaultPolicyCacheTTL = 5 * time.Minute + func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClient, logger *zerolog.Logger, opts ...PolicyVerifierOption) *PolicyVerifier { options := &PolicyVerifierOptions{} for _, opt := range opts { @@ -159,6 +180,14 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien maxConcurrency = defaultMaxConcurrency } + if options.PolicyCache == nil { + options.PolicyCache, _ = cache.New[*policyWithReference](cache.WithTTL(defaultPolicyCacheTTL)) + } + + if options.GroupCache == nil { + options.GroupCache, _ = cache.New[*groupWithReference](cache.WithTTL(defaultPolicyCacheTTL)) + } + return &PolicyVerifier{ policies: policies, client: client, @@ -170,6 +199,8 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien enablePrint: options.EnablePrint, evalPhase: options.EvalPhase, maxConcurrency: maxConcurrency, + policyCache: options.PolicyCache, + groupCache: options.GroupCache, } } @@ -593,7 +624,7 @@ func (pv *PolicyVerifier) getLoader(attachment *v1.PolicyAttachment) (Loader, er switch scheme { // No scheme means chainloop loader case chainloopScheme, "": - loader = NewChainloopLoader(pv.client) + loader = NewChainloopLoader(pv.client, pv.policyCache) case fileScheme: loader = new(FileLoader) case httpsScheme, httpScheme: diff --git a/pkg/policies/policy_groups.go b/pkg/policies/policy_groups.go index bbd339278..6822db3d6 100644 --- a/pkg/policies/policy_groups.go +++ b/pkg/policies/policy_groups.go @@ -24,6 +24,7 @@ import ( v13 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/cache" "github.com/chainloop-dev/chainloop/pkg/templates" intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" @@ -56,8 +57,9 @@ func (pgv *PolicyGroupVerifier) VerifyMaterial(ctx context.Context, material *ap for _, groupAtt := range groupAtts { // 1. load the policy group group, desc, err := LoadPolicyGroup(ctx, groupAtt, &LoadPolicyGroupOptions{ - Client: pgv.client, - Logger: pgv.logger, + Client: pgv.client, + Logger: pgv.logger, + GroupCache: pgv.groupCache, }) if err != nil { return nil, NewPolicyError(err) @@ -138,8 +140,9 @@ func (pgv *PolicyGroupVerifier) VerifyStatement(ctx context.Context, statement * attachments := pgv.policyGroups for _, groupAtt := range attachments { group, desc, err := LoadPolicyGroup(ctx, groupAtt, &LoadPolicyGroupOptions{ - Client: pgv.client, - Logger: pgv.logger, + Client: pgv.client, + Logger: pgv.logger, + GroupCache: pgv.groupCache, }) if err != nil { // Temporarily skip if policy groups still use old schema @@ -225,8 +228,9 @@ func applyGroupGate(policyAtt *v1.PolicyAttachment, groupAtt *v1.PolicyGroupAtta } type LoadPolicyGroupOptions struct { - Client v13.AttestationServiceClient - Logger *zerolog.Logger + Client v13.AttestationServiceClient + Logger *zerolog.Logger + GroupCache cache.Cache[*groupWithReference] } // LoadPolicyGroup loads a group (unmarshalls it) from a group attachment @@ -262,7 +266,11 @@ func getGroupLoader(attachment *v1.PolicyGroupAttachment, opts *LoadPolicyGroupO switch scheme { // No scheme means chainloop loader case chainloopScheme, "": - loader = NewChainloopGroupLoader(opts.Client) + groupCache := opts.GroupCache + if groupCache == nil { + groupCache, _ = cache.New[*groupWithReference](cache.WithTTL(defaultPolicyCacheTTL)) + } + loader = NewChainloopGroupLoader(opts.Client, groupCache) case fileScheme: loader = new(FileGroupLoader) case httpsScheme, httpScheme: