Skip to content

Commit 6d5405e

Browse files
authored
feat(extensions): OCI extension for attestation upload (#169)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 0311143 commit 6d5405e

File tree

12 files changed

+504
-28
lines changed

12 files changed

+504
-28
lines changed

app/cli/cmd/attached_integration_add.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func newAttachedIntegrationAttachCmd() *cobra.Command {
2828
Use: "add",
2929
Aliases: []string{"attach"},
3030
Short: "Attach an existing registered integration to a workflow",
31-
Example: ` chainloop integration attached add --workflow deadbeef --integration beefdoingwell --options projectName=MyProject`,
31+
Example: ` chainloop integration attached add --workflow deadbeef --integration beefdoingwell --opt projectName=MyProject --opt projectVersion=1.0.0`,
3232
RunE: func(cmd *cobra.Command, args []string) error {
3333
// Find the integration to extract the kind of integration we care about
3434
integration, err := action.NewRegisteredIntegrationDescribe(actionOpts).Run(integrationID)
@@ -67,7 +67,9 @@ func newAttachedIntegrationAttachCmd() *cobra.Command {
6767
cmd.Flags().StringVar(&workflowID, "workflow", "", "ID of the workflow to attach this integration")
6868
cobra.CheckErr(cmd.MarkFlagRequired("workflow"))
6969

70-
cmd.Flags().StringSliceVar(&options, "options", nil, "integration attachment arguments")
70+
// StringSlice seems to struggle with comma-separated values such as p12 jsonKeys provided as passwords
71+
// So we need to use StringArrayVar instead
72+
cmd.Flags().StringArrayVar(&options, "opt", nil, "integration attachment arguments")
7173

7274
return cmd
7375
}

app/cli/cmd/attached_integration_list.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,17 @@ func attachedIntegrationListTableOutput(attachments []*action.AttachedIntegratio
5757
wf := i.Workflow
5858
integration := i.Integration
5959

60-
maps.Copy(i.Config, integration.Config)
6160
var options []string
62-
for k, v := range i.Config {
63-
if v == "" {
64-
continue
61+
if i.Config != nil {
62+
maps.Copy(i.Config, integration.Config)
63+
for k, v := range i.Config {
64+
if v == "" {
65+
continue
66+
}
67+
options = append(options, fmt.Sprintf("%s: %v", k, v))
6568
}
66-
options = append(options, fmt.Sprintf("%s: %v", k, v))
6769
}
70+
6871
t.AppendRow(table.Row{i.ID, integration.Kind, strings.Join(options, "\n"), i.CreatedAt.Format(time.RFC822), wf.NamespacedName()})
6972
t.AppendSeparator()
7073
}

app/cli/cmd/registered_integration_add.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func newRegisteredIntegrationAddCmd() *cobra.Command {
3333
cmd := &cobra.Command{
3434
Use: "add INTEGRATION_ID --options key=value,key=value",
3535
Short: "Register a new instance of an integration",
36-
Example: ` chainloop integration registered add dependencytrack --options instance=https://deptrack.company.com,apiKey=1234567890`,
36+
Example: ` chainloop integration registered add dependencytrack --opt instance=https://deptrack.company.com,apiKey=1234567890 --opt username=chainloop`,
3737
Args: cobra.ExactArgs(1),
3838
RunE: func(cmd *cobra.Command, args []string) error {
3939
// Retrieve schema for validation and options marshaling
@@ -66,7 +66,9 @@ func newRegisteredIntegrationAddCmd() *cobra.Command {
6666
}
6767

6868
cmd.Flags().StringVar(&integrationDescription, "description", "", "integration registration description")
69-
cmd.Flags().StringSliceVar(&options, "options", nil, "integration arguments")
69+
// StringSlice seems to struggle with comma-separated values such as p12 jsonKeys provided as passwords
70+
// So we need to use StringArrayVar instead
71+
cmd.Flags().StringArrayVar(&options, "opt", nil, "integration arguments")
7072

7173
return cmd
7274
}
@@ -93,13 +95,14 @@ func parseAndValidateOpts(opts []string, schema *action.JSONSchema) (map[string]
9395
return res, nil
9496
}
9597

98+
// parseKeyValOpts performs two steps
99+
// 1 - Split the options into key/value pairs
100+
// 2 - Cast the values to the expected type defined in the schema
96101
func parseKeyValOpts(opts []string, propertiesMap action.SchemaPropertiesMap) (map[string]any, error) {
97-
// Two steps process
98-
99102
// 1 - Split the options into key/value pairs
100103
var options = make(map[string]any)
101104
for _, opt := range opts {
102-
kv := strings.Split(opt, "=")
105+
kv := strings.SplitN(opt, "=", 2)
103106
if len(kv) != 2 {
104107
return nil, fmt.Errorf("invalid option %q, the expected format is key=value", opt)
105108
}

app/controlplane/extensions/core/dependencytrack/v1/extension.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,6 @@ type attachmentConfig struct {
5959

6060
const description = "Send CycloneDX SBOMs to your Dependency-Track instance"
6161

62-
// Attach attaches the integration service to the given grpc server.
63-
// In the future this will be a plugin entrypoint
6462
func New(l log.Logger) (sdk.FanOut, error) {
6563
base, err := sdk.NewFanOut(
6664
&sdk.NewParams{
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# OCI registry extension
2+
3+
Send attestations to a compatible OCI repository.
4+
5+
## How to use it
6+
7+
1. To get started, you need to register the extension in your Chainloop organization.
8+
9+
```console
10+
$ chainloop integration registered add oci-registry --opt repository=[repo] --opt username=[username] --opt password=[password]
11+
```
12+
13+
2. When attaching the integration to your workflow, you have the option to specify an image name prefix:
14+
15+
```console
16+
chainloop integration attached add --workflow $WID --integration $IID --opt prefix=custom-prefix
17+
```
18+
19+
## Examples different providers
20+
21+
See below a non-exhaustive list of examples for different OCI registry providers known to work well with this extension.
22+
23+
### Google Artifact Registry
24+
25+
Using json-based service account https://console.cloud.google.com/iam-admin/serviceaccounts
26+
27+
```console
28+
$ chainloop integration registered add oci-registry \
29+
# i.e us-east1-docker.pkg.dev/my-project/chainloop-cas-devel
30+
--opt repository=[region]-docker.pkg.dev/[my-project]/[my-repository] \
31+
--opt username=_json_key \
32+
--opt "password=$(cat service-account.json)"
33+
```
34+
35+
### GitHub packages
36+
37+
Using personal access token with write:packages permissions https://github.com/settings/tokens
38+
39+
```console
40+
$ chainloop integration registered add oci-registry \
41+
# i.e ghcr.io/chainloop-dev/chainloop-cas
42+
--opt repository=ghcr.io/[username or org]/[my-repository] \
43+
--opt username=[username] \
44+
--opt password=[personal access token]
45+
```
46+
47+
### DockerHub
48+
49+
Create a personal access token at https://hub.docker.com/settings/security
50+
51+
```console
52+
$ chainloop integration registered add oci-registry \
53+
--opt repository=index.docker.io/[username] \
54+
--opt username=[username] \
55+
--opt password=[personal access token]
56+
```
57+
58+
### AWS Container Registry
59+
60+
Not supported at the moment
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package ociregistry
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
25+
v1 "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1"
26+
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1"
27+
"github.com/chainloop-dev/chainloop/internal/blobmanager/oci"
28+
"github.com/chainloop-dev/chainloop/internal/ociauth"
29+
"github.com/go-kratos/kratos/v2/log"
30+
cr_v1 "github.com/google/go-containerregistry/pkg/v1"
31+
)
32+
33+
type Integration struct {
34+
*sdk.FanOutIntegration
35+
}
36+
37+
// 1 - API schema definitions
38+
type registrationRequest struct {
39+
// Repository is not fully URI compliant and hence can not be validated with jsonschema
40+
Repository string `json:"repository" jsonschema:"minLength=1,description=OCI repository uri and path"`
41+
Username string `json:"username" jsonschema:"minLength=1,description=OCI repository username"`
42+
Password string `json:"password" jsonschema:"minLength=1,description=OCI repository password"`
43+
}
44+
45+
type attachmentRequest struct {
46+
Prefix string `json:"prefix,omitempty" jsonschema:"minLength=1,description=OCI images name prefix (default chainloop)"`
47+
}
48+
49+
// 2 - Configuration state
50+
type registrationState struct {
51+
Repository string `json:"repository"`
52+
}
53+
54+
type attachmentState struct {
55+
Prefix string `json:"prefix"`
56+
}
57+
58+
func New(l log.Logger) (sdk.FanOut, error) {
59+
base, err := sdk.NewFanOut(
60+
&sdk.NewParams{
61+
ID: "oci-registry",
62+
Version: "0.1",
63+
Description: "Send attestations to a compatible OCI registry",
64+
Logger: l,
65+
InputSchema: &sdk.InputSchema{
66+
Registration: &registrationRequest{},
67+
Attachment: &attachmentRequest{},
68+
},
69+
},
70+
sdk.WithEnvelope(),
71+
)
72+
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
return &Integration{base}, nil
78+
}
79+
80+
// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization
81+
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
82+
i.Logger.Info("registration requested")
83+
84+
// Extract request payload
85+
var request *registrationRequest
86+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
87+
return nil, fmt.Errorf("invalid registration request: %w", err)
88+
}
89+
90+
// Create and validate OCI credentials
91+
k, err := ociauth.NewCredentials(request.Repository, request.Username, request.Password)
92+
if err != nil {
93+
return nil, fmt.Errorf("the provided credentials are invalid")
94+
}
95+
96+
// Check write permissions
97+
b, err := oci.NewBackend(request.Repository, &oci.RegistryOptions{Keychain: k})
98+
if err != nil {
99+
return nil, fmt.Errorf("the provided credentials are invalid")
100+
}
101+
102+
if err := b.CheckWritePermissions(context.TODO()); err != nil {
103+
return nil, fmt.Errorf("the provided credentials don't have write permissions")
104+
}
105+
106+
// They seem valid, let's store them in the configuration and credentials state
107+
response := &sdk.RegistrationResponse{}
108+
109+
// a) Configuration State
110+
rawConfig, err := sdk.ToConfig(&registrationState{
111+
Repository: request.Repository,
112+
})
113+
if err != nil {
114+
return nil, fmt.Errorf("marshalling configuration: %w", err)
115+
}
116+
117+
response.Configuration = rawConfig
118+
119+
// b) Credentials state
120+
response.Credentials = &sdk.Credentials{Password: request.Password, Username: request.Username}
121+
122+
return response, nil
123+
}
124+
125+
// Attachment is executed when to attach a registered instance of this integration to a specific workflow
126+
func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
127+
// Extract request payload
128+
var request *attachmentRequest
129+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
130+
return nil, fmt.Errorf("invalid registration request: %w", err)
131+
}
132+
133+
// Define the state to be stored
134+
config, err := sdk.ToConfig(&attachmentState{Prefix: request.Prefix})
135+
if err != nil {
136+
return nil, fmt.Errorf("marshalling configuration: %w", err)
137+
}
138+
139+
return &sdk.AttachmentResponse{Configuration: config}, nil
140+
}
141+
142+
func (i *Integration) Execute(ctx context.Context, req *sdk.ExecutionRequest) error {
143+
i.Logger.Info("execution requested")
144+
145+
if err := validateExecuteRequest(req); err != nil {
146+
return fmt.Errorf("running validation: %w", err)
147+
}
148+
149+
// Extract registration configuration and credentials
150+
var registrationConfig *registrationState
151+
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &registrationConfig); err != nil {
152+
return fmt.Errorf("invalid registration configuration %w", err)
153+
}
154+
155+
// Extract attachment configuration
156+
var attachmentConfig *attachmentState
157+
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &attachmentConfig); err != nil {
158+
return fmt.Errorf("invalid attachment configuration %w", err)
159+
}
160+
161+
// Create OCI backend client
162+
credentials := req.RegistrationInfo.Credentials
163+
k, err := ociauth.NewCredentials(registrationConfig.Repository, credentials.Username, credentials.Password)
164+
if err != nil {
165+
return fmt.Errorf("setting up the keychain: %w", err)
166+
}
167+
168+
// Add prefix if provided
169+
var opts = make([]oci.NewBackendOpt, 0)
170+
if attachmentConfig.Prefix != "" {
171+
opts = append(opts, oci.WithPrefix(attachmentConfig.Prefix))
172+
}
173+
174+
ociClient, err := oci.NewBackend(registrationConfig.Repository, &oci.RegistryOptions{Keychain: k}, opts...)
175+
if err != nil {
176+
return fmt.Errorf("creating OCI backend %w", err)
177+
}
178+
179+
i.Logger.Infow("msg", "Uploading attestation", "repo", registrationConfig.Repository, "workflowID", req.WorkflowID)
180+
181+
// Perform the upload of the json marshalled attestation
182+
jsonContent, err := json.Marshal(req.Input.DSSEnvelope)
183+
if err != nil {
184+
return fmt.Errorf("marshaling the envelope: %w", err)
185+
}
186+
187+
// Calculate digest since it will be used as CAS reference
188+
h, _, err := cr_v1.SHA256(bytes.NewBuffer(jsonContent))
189+
if err != nil {
190+
return fmt.Errorf("calculating the digest: %w", err)
191+
}
192+
193+
if err := ociClient.Upload(ctx, bytes.NewBuffer(jsonContent), &v1.CASResource{Digest: h.Hex, FileName: "attestation.json"}); err != nil {
194+
return fmt.Errorf("uploading the attestation: %w", err)
195+
}
196+
197+
i.Logger.Infow("msg", "Attestation uploaded", "repo", registrationConfig.Repository, "workflowID", req.WorkflowID)
198+
199+
return nil
200+
}
201+
202+
// Validate that we are receiving an envelope
203+
// and the credentials and state from the registration stage
204+
func validateExecuteRequest(req *sdk.ExecutionRequest) error {
205+
if req == nil || req.Input == nil {
206+
return errors.New("execution input not received")
207+
}
208+
209+
if req.Input.DSSEnvelope == nil {
210+
return errors.New("execution input invalid, the envelope is empty")
211+
}
212+
213+
if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil {
214+
return errors.New("missing registration configuration")
215+
}
216+
217+
if req.RegistrationInfo.Credentials == nil {
218+
return errors.New("missing credentials")
219+
}
220+
221+
return nil
222+
}

0 commit comments

Comments
 (0)