@@ -17,16 +17,21 @@ package action
1717
1818import (
1919 "context"
20+ "encoding/json"
2021 "errors"
2122 "fmt"
23+ "os"
2224 "strconv"
2325
2426 "github.com/chainloop-dev/chainloop/app/cli/internal/token"
2527 pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
2628 v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2729 "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal"
30+ "github.com/chainloop-dev/chainloop/internal/prinfo"
2831 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
2932 clientAPI "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
33+ "github.com/chainloop-dev/chainloop/pkg/casclient"
34+ "github.com/chainloop-dev/chainloop/pkg/grpcconn"
3035 "github.com/chainloop-dev/chainloop/pkg/policies"
3136 "github.com/rs/zerolog"
3237)
@@ -37,16 +42,22 @@ type AttestationInitOpts struct {
3742 // Force the initialization and override any existing, in-progress ones.
3843 // Note that this is only useful when local-based attestation state is configured
3944 // since it's a protection to make sure you don't override the state by mistake
40- Force bool
41- UseRemoteState bool
42- LocalStatePath string
45+ Force bool
46+ UseRemoteState bool
47+ LocalStatePath string
48+ CASURI string
49+ CASCAPath string // optional CA certificate for the CAS connection
50+ ConnectionInsecure bool
4351}
4452
4553type AttestationInit struct {
4654 * ActionsOpts
47- dryRun , force bool
48- c * crafter.Crafter
49- useRemoteState bool
55+ dryRun , force bool
56+ c * crafter.Crafter
57+ useRemoteState bool
58+ casURI string
59+ casCAPath string
60+ connectionInsecure bool
5061}
5162
5263// ErrAttestationAlreadyExist means that there is an attestation in progress
@@ -67,11 +78,14 @@ func NewAttestationInit(cfg *AttestationInitOpts) (*AttestationInit, error) {
6778 }
6879
6980 return & AttestationInit {
70- ActionsOpts : cfg .ActionsOpts ,
71- c : c ,
72- dryRun : cfg .DryRun ,
73- force : cfg .Force ,
74- useRemoteState : cfg .UseRemoteState ,
81+ ActionsOpts : cfg .ActionsOpts ,
82+ c : c ,
83+ dryRun : cfg .DryRun ,
84+ force : cfg .Force ,
85+ useRemoteState : cfg .UseRemoteState ,
86+ casURI : cfg .CASURI ,
87+ casCAPath : cfg .CASCAPath ,
88+ connectionInsecure : cfg .ConnectionInsecure ,
7589 }, nil
7690}
7791
@@ -219,6 +233,44 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
219233 attestationID = workflowRun .GetId ()
220234 }
221235
236+ // Get CAS credentials for PR metadata upload
237+ var casBackend = & casclient.CASBackend {Name : "not-set" }
238+ if ! action .dryRun && attestationID != "" {
239+ creds , err := client .GetUploadCreds (ctx ,
240+ & pb.AttestationServiceGetUploadCredsRequest {
241+ WorkflowRunId : attestationID ,
242+ },
243+ )
244+ if err != nil {
245+ // Log warning but don't fail - will fall back to inline storage
246+ action .Logger .Warn ().Err (err ).Msg ("failed to get CAS credentials for PR metadata, will store inline" )
247+ } else {
248+ b := creds .GetResult ().GetBackend ()
249+ if b != nil {
250+ casBackend .Name = b .Provider
251+ casBackend .MaxSize = b .GetLimits ().MaxBytes
252+
253+ // Set up CAS connection if not inline
254+ if ! b .IsInline && creds .Result .Token != "" {
255+ var opts = []grpcconn.Option {
256+ grpcconn .WithInsecure (action .connectionInsecure ),
257+ }
258+ if action .casCAPath != "" {
259+ opts = append (opts , grpcconn .WithCAFile (action .casCAPath ))
260+ }
261+
262+ artifactCASConn , err := grpcconn .New (action .casURI , creds .Result .Token , opts ... )
263+ if err != nil {
264+ action .Logger .Warn ().Err (err ).Msg ("failed to create CAS connection, will store inline" )
265+ } else {
266+ defer artifactCASConn .Close ()
267+ casBackend .Uploader = casclient .New (artifactCASConn , casclient .WithLogger (action .Logger ))
268+ }
269+ }
270+ }
271+ }
272+ }
273+
222274 var authInfo * clientAPI.Attestation_Auth
223275 if action .AuthTokenRaw != "" {
224276 authInfo , err = extractAuthInfo (action .AuthTokenRaw )
@@ -268,6 +320,12 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
268320 return "" , err
269321 }
270322
323+ // Auto-collect PR/MR metadata if in PR/MR context
324+ if err := action .autoCollectPRMetadata (ctx , attestationID , discoveredRunner , casBackend ); err != nil {
325+ action .Logger .Warn ().Err (err ).Msg ("failed to auto-collect PR/MR metadata" )
326+ // Don't fail the init - this is best-effort
327+ }
328+
271329 return attestationID , nil
272330}
273331
@@ -371,6 +429,64 @@ func extractAuthInfo(authToken string) (*clientAPI.Attestation_Auth, error) {
371429 }, nil
372430}
373431
432+ // autoCollectPRMetadata automatically collects PR/MR metadata if running in a PR/MR context
433+ func (action * AttestationInit ) autoCollectPRMetadata (ctx context.Context , attestationID string , runner crafter.SupportedRunner , casBackend * casclient.CASBackend ) error {
434+ // Detect if we're in a PR/MR context
435+ isPR , metadata , err := crafter .DetectPRContext (runner )
436+ if err != nil {
437+ return fmt .Errorf ("failed to detect PR/MR context: %w" , err )
438+ }
439+
440+ // If not in PR/MR context, nothing to do
441+ if ! isPR {
442+ action .Logger .Debug ().Msg ("not in PR/MR context, skipping metadata collection" )
443+ return nil
444+ }
445+
446+ action .Logger .Info ().Str ("platform" , metadata .Platform ).Str ("number" , metadata .Number ).Msg ("detected PR/MR context" )
447+
448+ // Create the material
449+ evidenceData := prinfo .NewEvidence (prinfo.Data {
450+ Platform : metadata .Platform ,
451+ Type : metadata .Type ,
452+ Number : metadata .Number ,
453+ Title : metadata .Title ,
454+ Description : metadata .Description ,
455+ SourceBranch : metadata .SourceBranch ,
456+ TargetBranch : metadata .TargetBranch ,
457+ URL : metadata .URL ,
458+ Author : metadata .Author ,
459+ })
460+
461+ // Marshal to JSON
462+ jsonData , err := json .Marshal (evidenceData )
463+ if err != nil {
464+ return fmt .Errorf ("failed to marshal PR/MR metadata: %w" , err )
465+ }
466+
467+ // Create a temporary file for the metadata
468+ tmpFile , err := os .CreateTemp ("" , "pr-metadata-*.json" )
469+ if err != nil {
470+ return fmt .Errorf ("failed to create temp file: %w" , err )
471+ }
472+ defer os .Remove (tmpFile .Name ())
473+
474+ // Write the JSON data to the temp file
475+ if _ , err := tmpFile .Write (jsonData ); err != nil {
476+ tmpFile .Close ()
477+ return fmt .Errorf ("failed to write metadata to temp file: %w" , err )
478+ }
479+ tmpFile .Close ()
480+
481+ // Add the material using the crafter with explicit CHAINLOOP_PR_INFO type
482+ if _ , err := action .c .AddMaterialContractFree (ctx , attestationID , v1 .CraftingSchema_Material_CHAINLOOP_PR_INFO .String (), "pr-metadata" , tmpFile .Name (), casBackend , nil ); err != nil {
483+ return fmt .Errorf ("failed to add PR/MR metadata material: %w" , err )
484+ }
485+
486+ action .Logger .Info ().Msg ("successfully collected and attested PR/MR metadata" )
487+ return nil
488+ }
489+
374490// parseContractV2 attempts to parse a raw contract as V2 schema
375491func parseContractV2 (rawContract * pb.WorkflowContractVersionItem_RawBody ) * v1.CraftingSchemaV2 {
376492 if rawContract == nil {
0 commit comments