Skip to content

Commit 1f82638

Browse files
authored
feat(dependecy-track): interpolated project name (#282)
Signed-off-by: Miguel Martinez Trivino <[email protected]>
1 parent c78fb7e commit 1f82638

File tree

3 files changed

+180
-49
lines changed

3 files changed

+180
-49
lines changed

app/controlplane/plugins/core/dependency-track/v1/extension.go

Lines changed: 106 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"errors"
2222
"fmt"
23+
"text/template"
2324

2425
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2526
"github.com/chainloop-dev/chainloop/app/controlplane/plugins/core/dependency-track/v1/client"
@@ -63,7 +64,7 @@ func New(l log.Logger) (sdk.FanOut, error) {
6364
base, err := sdk.NewFanOut(
6465
&sdk.NewParams{
6566
ID: "dependency-track",
66-
Version: "1.2",
67+
Version: "1.3",
6768
Description: description,
6869
Logger: l,
6970
InputSchema: &sdk.InputSchema{
@@ -143,67 +144,117 @@ func (i *DependencyTrack) Attach(ctx context.Context, req *sdk.AttachmentRequest
143144
return &sdk.AttachmentResponse{Configuration: rawConfig}, nil
144145
}
145146

146-
// Send the SBOM to the configured Dependency Track instance
147+
// Send the SBOMs to the configured Dependency Track instance
147148
func (i *DependencyTrack) Execute(ctx context.Context, req *sdk.ExecutionRequest) error {
148-
i.Logger.Info("execution requested")
149-
149+
var errs error
150150
// Iterate over all SBOMs
151151
for _, sbom := range req.Input.Materials {
152-
// Make sure it's an SBOM and all the required configuration has been received
153-
if err := validateExecuteOpts(sbom, req.RegistrationInfo, req.AttachmentInfo); err != nil {
154-
return fmt.Errorf("running validation: %w", err)
152+
if err := doExecute(ctx, req, sbom, i.Logger); err != nil {
153+
errs = errors.Join(errs, err)
154+
continue
155155
}
156+
}
156157

157-
// Extract registration configuration
158-
var registrationConfig *registrationConfig
159-
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &registrationConfig); err != nil {
160-
return errors.New("invalid registration configuration")
161-
}
158+
if errs != nil {
159+
return fmt.Errorf("executing: %w", errs)
160+
}
162161

163-
// Extract attachment configuration
164-
var attachmentConfig *attachmentConfig
165-
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &attachmentConfig); err != nil {
166-
return errors.New("invalid attachment configuration")
167-
}
162+
return nil
163+
}
168164

169-
i.Logger.Infow("msg", "Uploading SBOM",
170-
"materialName", sbom.Name,
171-
"host", registrationConfig.Domain,
172-
"projectID", attachmentConfig.ProjectID, "projectName", attachmentConfig.ProjectName,
173-
"workflowID", req.Workflow.ID,
174-
)
175-
176-
// Create an SBOM client and perform validation and upload
177-
d, err := client.NewSBOMUploader(registrationConfig.Domain,
178-
req.RegistrationInfo.Credentials.Password,
179-
bytes.NewReader(sbom.Content),
180-
attachmentConfig.ProjectID,
181-
attachmentConfig.ProjectName)
182-
if err != nil {
183-
return fmt.Errorf("creating uploader: %w", err)
184-
}
165+
func doExecute(ctx context.Context, req *sdk.ExecutionRequest, sbom *sdk.ExecuteMaterial, l *log.Helper) error {
166+
l.Info("execution requested")
185167

186-
if err := d.Validate(ctx); err != nil {
187-
return fmt.Errorf("validating uploader: %w", err)
188-
}
168+
// Make sure it's an SBOM and all the required configuration has been received
169+
if err := validateExecuteOpts(sbom, req.RegistrationInfo, req.AttachmentInfo); err != nil {
170+
return fmt.Errorf("running validation: %w", err)
171+
}
189172

190-
if err := d.Do(ctx); err != nil {
191-
return fmt.Errorf("uploading SBOM: %w", err)
192-
}
173+
// Extract registration configuration
174+
var registrationConfig *registrationConfig
175+
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &registrationConfig); err != nil {
176+
return errors.New("invalid registration configuration")
177+
}
193178

194-
i.Logger.Infow("msg", "SBOM Uploaded",
195-
"materialName", sbom.Name,
196-
"host", registrationConfig.Domain,
197-
"projectID", attachmentConfig.ProjectID, "projectName", attachmentConfig.ProjectName,
198-
"workflowID", req.Workflow.ID,
199-
)
179+
// Extract attachment configuration
180+
var attachmentConfig *attachmentConfig
181+
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &attachmentConfig); err != nil {
182+
return errors.New("invalid attachment configuration")
183+
}
184+
185+
projectName, err := resolveProjectName(attachmentConfig.ProjectName, sbom.Annotations)
186+
if err != nil {
187+
// If we can't find the annotation for example, we skip the SBOM
188+
l.Infow("msg", "failed to resolve project name, SKIPPING", "err", err, "materialName", sbom.Name)
189+
return nil
200190
}
201191

202-
i.Logger.Info("execution finished")
192+
l.Infow("msg", "Uploading SBOM",
193+
"materialName", sbom.Name,
194+
"host", registrationConfig.Domain,
195+
"projectID", attachmentConfig.ProjectID, "projectName", projectName,
196+
"workflowID", req.Workflow.ID,
197+
)
198+
199+
// Create an SBOM client and perform validation and upload
200+
d, err := client.NewSBOMUploader(registrationConfig.Domain,
201+
req.RegistrationInfo.Credentials.Password,
202+
bytes.NewReader(sbom.Content),
203+
attachmentConfig.ProjectID,
204+
projectName)
205+
if err != nil {
206+
return fmt.Errorf("creating uploader: %w", err)
207+
}
208+
209+
if err := d.Validate(ctx); err != nil {
210+
return fmt.Errorf("validating uploader: %w", err)
211+
}
212+
213+
if err := d.Do(ctx); err != nil {
214+
return fmt.Errorf("uploading SBOM: %w", err)
215+
}
216+
217+
l.Infow("msg", "SBOM Uploaded",
218+
"materialName", sbom.Name,
219+
"host", registrationConfig.Domain,
220+
"projectID", attachmentConfig.ProjectID, "projectName", projectName,
221+
"workflowID", req.Workflow.ID,
222+
)
223+
224+
l.Info("execution finished")
203225

204226
return nil
205227
}
206228

229+
type interpolationContext struct {
230+
Material *interpolationContextMaterial
231+
}
232+
type interpolationContextMaterial struct {
233+
Annotations map[string]string
234+
}
235+
236+
// Resolve the project name template.
237+
// We currently support the following template variables:
238+
// - material.annotations.<key>
239+
// For example, project-name => {{ material.annotations.my_annotation }}
240+
func resolveProjectName(projectNameTpl string, annotations map[string]string) (string, error) {
241+
data := interpolationContext{&interpolationContextMaterial{annotations}}
242+
243+
// The project name can contain template variables, useful to include annotations for example
244+
// We do fail if the key can't be found
245+
tpl, err := template.New("projectName").Option("missingkey=error").Parse(projectNameTpl)
246+
if err != nil {
247+
return "", fmt.Errorf("invalid project name: %w", err)
248+
}
249+
250+
buf := bytes.NewBuffer(nil)
251+
if err := tpl.Execute(buf, data); err != nil {
252+
return "", fmt.Errorf("executing template: %w", err)
253+
}
254+
255+
return buf.String(), nil
256+
}
257+
207258
// i.e we want to attach to a dependency track integration and we are proving the right attachment options
208259
// Not only syntactically but also semantically, i.e we can only request auto-creation of projects if the integration allows it
209260
func validateAttachment(ctx context.Context, rc *registrationConfig, ac *attachmentRequest, credentials *sdk.Credentials) error {
@@ -229,8 +280,15 @@ func validateAttachmentConfiguration(rc *registrationConfig, ac *attachmentReque
229280
return errors.New("invalid configuration")
230281
}
231282

232-
if ac.ProjectName != "" && !rc.AllowAutoCreate {
233-
return errors.New("auto creation of projects is not supported in this integration")
283+
if ac.ProjectName != "" {
284+
if !rc.AllowAutoCreate {
285+
return errors.New("auto creation of projects is not supported in this integration")
286+
}
287+
288+
// The project name can contain template variables, useful to include annotations for example
289+
if _, err := template.New("projectName").Parse(ac.ProjectName); err != nil {
290+
return fmt.Errorf("invalid project name: %w", err)
291+
}
234292
}
235293

236294
if ac.ProjectID == "" && ac.ProjectName == "" {

app/controlplane/plugins/core/dependency-track/v1/extension_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,79 @@ func TestValidateRegistrationInput(t *testing.T) {
7777
}
7878
}
7979

80+
func TestResolveProjectName(t *testing.T) {
81+
testCases := []struct {
82+
name string
83+
projectName string
84+
wantErr bool
85+
want string
86+
}{
87+
{
88+
name: "no interpolation",
89+
projectName: "hi",
90+
want: "hi",
91+
wantErr: false,
92+
},
93+
{
94+
name: "no interpolation",
95+
projectName: "{.Hello}",
96+
want: "{.Hello}",
97+
wantErr: false,
98+
},
99+
{
100+
name: "nope",
101+
projectName: "{.Hello",
102+
want: "{.Hello",
103+
wantErr: false,
104+
},
105+
{
106+
name: "invalid template",
107+
projectName: "{{.Hello",
108+
wantErr: true,
109+
},
110+
{
111+
name: "interpolated key",
112+
projectName: "{{.Material.Annotations.Hello}}",
113+
want: "hola",
114+
},
115+
{
116+
name: "interpolated string",
117+
projectName: "{{.Material.Annotations.Hello}}-project",
118+
want: "hola-project",
119+
},
120+
{
121+
name: "non-existing",
122+
projectName: "{{.Material.Annotations.noVal}}",
123+
want: "",
124+
wantErr: true,
125+
},
126+
{
127+
name: "non-existing-case",
128+
projectName: "{{.Material.Annotations.hello}}",
129+
wantErr: true,
130+
},
131+
}
132+
133+
data := map[string]string{
134+
"Hello": "hola",
135+
"World": "mundo",
136+
}
137+
138+
for _, tc := range testCases {
139+
t.Run(tc.name, func(t *testing.T) {
140+
var err error
141+
got, err := resolveProjectName(tc.projectName, data)
142+
if tc.wantErr {
143+
assert.Error(t, err)
144+
return
145+
}
146+
147+
assert.NoError(t, err)
148+
assert.Equal(t, tc.want, got)
149+
})
150+
}
151+
}
152+
80153
func TestValidateAttachmentInput(t *testing.T) {
81154
testCases := []struct {
82155
name string

docs/integrations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Below you can find the list of currently available integrations. If you can't fi
1010

1111
| ID | Version | Description | Material Requirement |
1212
| --- | --- | --- | --- |
13-
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.2 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
13+
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.3 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
1414
| [discord-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/discord-webhook/v1/README.md) | 1.1 | Send attestations to Discord | |
1515
| [guac](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/guac/v1/README.md) | 1.0 | Export Attestation and SBOMs metadata to a blob storage backend so guacsec/guac can consume it | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON |
1616
| [oci-registry](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/oci-registry/v1/README.md) | 1.0 | Send attestations to a compatible OCI registry | |

0 commit comments

Comments
 (0)