Skip to content

Commit a699e8e

Browse files
committed
feat(controlplane): add attestation bundle cache with layered resolution
Add a NATS KV / in-memory cache for attestation bundles in the controlplane, consistent with the platform's caching strategy. The bundle resolution in addAttestationFromBundle now follows a 3-layer strategy: cache lookup by digest, DB fallback, and CAS download. This prepares for eventually dropping the attestation/bundle DB columns. Closes: #3003 Signed-off-by: Miguel Martinez Trivino <migmartri@gmail.com> Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent b046855 commit a699e8e

7 files changed

Lines changed: 196 additions & 18 deletions

File tree

app/controlplane/cmd/wire.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ var cacheProviderSet = wire.NewSet(
141141
newMembershipsCache,
142142
newClaimsCache,
143143
newPolicyEvalBundleCache,
144+
newAttestationBundleCache,
144145
)
145146

146147
func newClaimsCache(ctx context.Context, rc *natsconn.ReloadableConnection, logger log.Logger) (cache.Cache[*jwt.MapClaims], error) {
@@ -182,6 +183,23 @@ func newPolicyEvalBundleCache(ctx context.Context, rc *natsconn.ReloadableConnec
182183
return cache.New[[]byte](opts...)
183184
}
184185

186+
func newAttestationBundleCache(ctx context.Context, rc *natsconn.ReloadableConnection, logger log.Logger) (*biz.AttestationBundleCache, error) {
187+
l := log.NewHelper(logger)
188+
backend := "memory"
189+
opts := []cache.Option{cache.WithTTL(5 * 24 * time.Hour), cache.WithLogger(&kratosLogAdapter{h: l}), cache.WithDescription("Cache for attestation bundles")}
190+
if rc != nil {
191+
backend = "nats"
192+
opts = append(opts, cache.WithNATS(rc.Conn, "chainloop-attestation-bundles"))
193+
opts = append(opts, cache.WithReconnect(rc.Subscribe(ctx)))
194+
}
195+
l.Infow("msg", "cache initialized", "bucket", "chainloop-attestation-bundles", "backend", backend, "ttl", "120h")
196+
c, err := cache.New[[]byte](opts...)
197+
if err != nil {
198+
return nil, err
199+
}
200+
return &biz.AttestationBundleCache{Cache: c}, nil
201+
}
202+
185203
// kratosLogAdapter adapts kratos log.Helper (Debugw(...interface{})) to cache.Logger (Debugw(string, ...any)).
186204
type kratosLogAdapter struct{ h *log.Helper }
187205

app/controlplane/cmd/wire_gen.go

Lines changed: 35 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/pkg/biz/biz.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ var ProviderSet = wire.NewSet(
3434
NewUserUseCase,
3535
NewRootAccountUseCase,
3636
NewWorkflowRunUseCase,
37+
wire.Struct(new(WorkflowRunUseCaseOpts), "*"),
3738
NewOrganizationUseCase,
3839
NewWorkflowContractUseCase,
3940
NewCASCredentialsUseCase,

app/controlplane/pkg/biz/testhelpers/wire.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,20 @@ func WireTestData(context.Context, *TestDatabase, *testing.T, log.Logger, creden
6565
authzConfig,
6666
authzUseCaseConfig,
6767
biz.NewIndexConfig,
68+
newAttestationBundleCache,
69+
newNilCASClient,
6870
),
6971
)
7072
}
7173

74+
func newAttestationBundleCache() *biz.AttestationBundleCache {
75+
return nil
76+
}
77+
78+
func newNilCASClient() biz.CASClient {
79+
return nil
80+
}
81+
7282
func authzConfig() *authz.Config {
7383
return &authz.Config{RolesMap: authz.RolesMap}
7484
}

app/controlplane/pkg/biz/testhelpers/wire_gen.go

Lines changed: 23 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/pkg/biz/workflowrun.go

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@ import (
3030
"github.com/chainloop-dev/chainloop/pkg/attestation"
3131
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
3232
"github.com/chainloop-dev/chainloop/pkg/attestation/verifier"
33+
"github.com/chainloop-dev/chainloop/pkg/cache"
3334
"github.com/secure-systems-lab/go-securesystemslib/dsse"
3435
protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
3536
"github.com/sigstore/sigstore/pkg/cryptoutils"
@@ -103,24 +104,50 @@ type WorkflowRunRepo interface {
103104
Expire(ctx context.Context, id uuid.UUID) error
104105
}
105106

107+
// AttestationBundleCache wraps cache.Cache[[]byte] to disambiguate from other
108+
// []byte caches (e.g. policy evaluation bundles) in the wire dependency graph.
109+
type AttestationBundleCache struct {
110+
cache.Cache[[]byte]
111+
}
112+
106113
type WorkflowRunUseCase struct {
107114
wfRunRepo WorkflowRunRepo
108115
wfRepo WorkflowRepo
109116
logger *log.Helper
110117
auditorUC *AuditorUseCase
111118

112119
signingUseCase *SigningUseCase
120+
bundleCache *AttestationBundleCache
121+
casClient CASClient
122+
casMappingUC *CASMappingUseCase
123+
}
124+
125+
type WorkflowRunUseCaseOpts struct {
126+
WfrRepo WorkflowRunRepo
127+
WfRepo WorkflowRepo
128+
SigningUC *SigningUseCase
129+
AuditorUC *AuditorUseCase
130+
Logger log.Logger
131+
BundleCache *AttestationBundleCache
132+
CASClient CASClient
133+
CASMappingUC *CASMappingUseCase
113134
}
114135

115-
func NewWorkflowRunUseCase(wfrRepo WorkflowRunRepo, wfRepo WorkflowRepo, suc *SigningUseCase, auditorUC *AuditorUseCase, logger log.Logger) (*WorkflowRunUseCase, error) {
136+
func NewWorkflowRunUseCase(opts *WorkflowRunUseCaseOpts) (*WorkflowRunUseCase, error) {
137+
logger := opts.Logger
116138
if logger == nil {
117139
logger = log.NewStdLogger(io.Discard)
118140
}
119141

120142
return &WorkflowRunUseCase{
121-
wfRunRepo: wfrRepo, wfRepo: wfRepo, auditorUC: auditorUC,
122-
signingUseCase: suc,
143+
wfRunRepo: opts.WfrRepo,
144+
wfRepo: opts.WfRepo,
145+
auditorUC: opts.AuditorUC,
146+
signingUseCase: opts.SigningUC,
123147
logger: log.NewHelper(logger),
148+
bundleCache: opts.BundleCache,
149+
casClient: opts.CASClient,
150+
casMappingUC: opts.CASMappingUC,
124151
}, nil
125152
}
126153

@@ -535,29 +562,97 @@ func (uc *WorkflowRunUseCase) GetByDigestInOrgOrPublic(ctx context.Context, orgI
535562
return workflowRunInOrgOrPublic(wfrun, orgUUID)
536563
}
537564

565+
// addAttestationFromBundle resolves the attestation bundle using cache → DB → CAS fallback.
566+
// Bundles are being migrated from the workflow run DB column into CAS; the layered resolution
567+
// provides backward compatibility and prepares for dropping the DB column.
538568
func (uc *WorkflowRunUseCase) addAttestationFromBundle(ctx context.Context, wfRun *WorkflowRun) error {
539-
// missing workflow run or attestation already there, do nothing
540569
if wfRun == nil || wfRun.State != string(WorkflowRunSuccess) {
541570
return nil
542571
}
543572

544-
var bundle protobundle.Bundle
545-
bundleBytes, err := uc.wfRunRepo.GetBundle(ctx, wfRun.ID)
546-
if err != nil {
547-
if IsNotFound(err) {
548-
return nil
573+
if wfRun.Attestation == nil || wfRun.Attestation.Digest == "" {
574+
return nil
575+
}
576+
577+
digest := wfRun.Attestation.Digest
578+
579+
// Layer 1: cache lookup by digest
580+
if uc.bundleCache != nil {
581+
cached, found, err := uc.bundleCache.Get(ctx, digest)
582+
if err != nil {
583+
uc.logger.Warnw("msg", "attestation bundle cache get error", "digest", digest, "error", err)
549584
}
585+
if found {
586+
return uc.applyBundle(wfRun, cached)
587+
}
588+
}
589+
590+
// Layer 2: DB fallback
591+
bundleBytes, err := uc.wfRunRepo.GetBundle(ctx, wfRun.ID)
592+
if err != nil && !IsNotFound(err) {
550593
return fmt.Errorf("retrieving bundle from repo: %w", err)
551594
}
552-
if err = protojson.Unmarshal(bundleBytes, &bundle); err != nil {
595+
596+
if len(bundleBytes) > 0 {
597+
uc.cacheBundle(ctx, digest, bundleBytes)
598+
return uc.applyBundle(wfRun, bundleBytes)
599+
}
600+
601+
// Layer 3: CAS download by digest
602+
bundleBytes, err = uc.downloadBundleFromCAS(ctx, digest, wfRun.Workflow.OrgID)
603+
if err != nil {
604+
uc.logger.Warnw("msg", "failed to download bundle from CAS", "digest", digest, "error", err)
605+
return nil
606+
}
607+
608+
if len(bundleBytes) > 0 {
609+
uc.cacheBundle(ctx, digest, bundleBytes)
610+
return uc.applyBundle(wfRun, bundleBytes)
611+
}
612+
613+
return nil
614+
}
615+
616+
func (uc *WorkflowRunUseCase) applyBundle(wfRun *WorkflowRun, bundleBytes []byte) error {
617+
var bundle protobundle.Bundle
618+
if err := protojson.Unmarshal(bundleBytes, &bundle); err != nil {
553619
return fmt.Errorf("unmarshalling bundle: %w", err)
554620
}
555621
wfRun.Attestation.Envelope = attestation.DSSEEnvelopeFromBundle(&bundle)
556622
wfRun.Attestation.Bundle = bundleBytes
557-
558623
return nil
559624
}
560625

626+
func (uc *WorkflowRunUseCase) cacheBundle(ctx context.Context, digest string, data []byte) {
627+
if uc.bundleCache == nil {
628+
return
629+
}
630+
if err := uc.bundleCache.Set(ctx, digest, data); err != nil {
631+
uc.logger.Warnw("msg", "failed to cache attestation bundle", "digest", digest, "error", err)
632+
}
633+
}
634+
635+
func (uc *WorkflowRunUseCase) downloadBundleFromCAS(ctx context.Context, digest string, orgID uuid.UUID) ([]byte, error) {
636+
if uc.casClient == nil || uc.casMappingUC == nil {
637+
return nil, nil
638+
}
639+
640+
mapping, err := uc.casMappingUC.FindCASMappingForDownloadByOrg(ctx, digest, []uuid.UUID{orgID}, nil)
641+
if err != nil {
642+
return nil, fmt.Errorf("finding CAS mapping: %w", err)
643+
}
644+
if mapping == nil {
645+
return nil, nil
646+
}
647+
648+
var buf bytes.Buffer
649+
if err := uc.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, &buf, digest); err != nil {
650+
return nil, fmt.Errorf("downloading attestation bundle: %w", err)
651+
}
652+
653+
return buf.Bytes(), nil
654+
}
655+
561656
// filter the workflow runs that belong to the org or are public
562657
func workflowRunInOrgOrPublic(wfRun *WorkflowRun, orgID uuid.UUID) (*WorkflowRun, error) {
563658
if wfRun == nil || (wfRun.Workflow.OrgID != orgID && !wfRun.Workflow.Public) {

app/controlplane/pkg/biz/workflowrun_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -53,7 +53,7 @@ type workflowrunTestSuite struct {
5353

5454
func (s *workflowrunTestSuite) SetupTest() {
5555
s.repo = repoM.NewWorkflowRunRepo(s.T())
56-
uc, err := biz.NewWorkflowRunUseCase(s.repo, nil, nil, nil, nil)
56+
uc, err := biz.NewWorkflowRunUseCase(&biz.WorkflowRunUseCaseOpts{WfrRepo: s.repo})
5757
require.NoError(s.T(), err)
5858
s.useCase = uc
5959
s.validID = uuid.New()

0 commit comments

Comments
 (0)