Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/cli/cmd/attached_integration_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func newAttachedIntegrationAttachCmd() *cobra.Command {
Use: "add",
Aliases: []string{"attach"},
Short: "Attach an existing registered integration to a workflow",
Example: ` chainloop integration attached add --workflow deadbeef --integration beefdoingwell --options projectName=MyProject`,
Example: ` chainloop integration attached add --workflow deadbeef --integration beefdoingwell --opt projectName=MyProject --opt projectVersion=1.0.0`,
RunE: func(cmd *cobra.Command, args []string) error {
// Find the integration to extract the kind of integration we care about
integration, err := action.NewRegisteredIntegrationDescribe(actionOpts).Run(integrationID)
Expand Down Expand Up @@ -67,7 +67,9 @@ func newAttachedIntegrationAttachCmd() *cobra.Command {
cmd.Flags().StringVar(&workflowID, "workflow", "", "ID of the workflow to attach this integration")
cobra.CheckErr(cmd.MarkFlagRequired("workflow"))

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

return cmd
}
13 changes: 8 additions & 5 deletions app/cli/cmd/attached_integration_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,17 @@ func attachedIntegrationListTableOutput(attachments []*action.AttachedIntegratio
wf := i.Workflow
integration := i.Integration

maps.Copy(i.Config, integration.Config)
var options []string
for k, v := range i.Config {
if v == "" {
continue
if i.Config != nil {
maps.Copy(i.Config, integration.Config)
for k, v := range i.Config {
if v == "" {
continue
}
options = append(options, fmt.Sprintf("%s: %v", k, v))
}
options = append(options, fmt.Sprintf("%s: %v", k, v))
}

t.AppendRow(table.Row{i.ID, integration.Kind, strings.Join(options, "\n"), i.CreatedAt.Format(time.RFC822), wf.NamespacedName()})
t.AppendSeparator()
}
Expand Down
13 changes: 8 additions & 5 deletions app/cli/cmd/registered_integration_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func newRegisteredIntegrationAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add INTEGRATION_ID --options key=value,key=value",
Short: "Register a new instance of an integration",
Example: ` chainloop integration registered add dependencytrack --options instance=https://deptrack.company.com,apiKey=1234567890`,
Example: ` chainloop integration registered add dependencytrack --opt instance=https://deptrack.company.com,apiKey=1234567890 --opt username=chainloop`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Retrieve schema for validation and options marshaling
Expand Down Expand Up @@ -66,7 +66,9 @@ func newRegisteredIntegrationAddCmd() *cobra.Command {
}

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

return cmd
}
Expand All @@ -93,13 +95,14 @@ func parseAndValidateOpts(opts []string, schema *action.JSONSchema) (map[string]
return res, nil
}

// parseKeyValOpts performs two steps
// 1 - Split the options into key/value pairs
// 2 - Cast the values to the expected type defined in the schema
func parseKeyValOpts(opts []string, propertiesMap action.SchemaPropertiesMap) (map[string]any, error) {
// Two steps process

// 1 - Split the options into key/value pairs
var options = make(map[string]any)
for _, opt := range opts {
kv := strings.Split(opt, "=")
kv := strings.SplitN(opt, "=", 2)
if len(kv) != 2 {
return nil, fmt.Errorf("invalid option %q, the expected format is key=value", opt)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ type attachmentConfig struct {

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

// Attach attaches the integration service to the given grpc server.
// In the future this will be a plugin entrypoint
func New(l log.Logger) (sdk.FanOut, error) {
base, err := sdk.NewFanOut(
&sdk.NewParams{
Expand Down
60 changes: 60 additions & 0 deletions app/controlplane/extensions/core/ociregistry/v1/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# OCI registry extension

Send attestations to a compatible OCI repository.

## How to use it

1. To get started, you need to register the extension in your Chainloop organization.

```console
$ chainloop integration registered add oci-registry --opt repository=[repo] --opt username=[username] --opt password=[password]
```

2. When attaching the integration to your workflow, you have the option to specify an image name prefix:

```console
chainloop integration attached add --workflow $WID --integration $IID --opt prefix=custom-prefix
```

## Examples different providers

See below a non-exhaustive list of examples for different OCI registry providers known to work well with this extension.

### Google Artifact Registry

Using json-based service account https://console.cloud.google.com/iam-admin/serviceaccounts

```console
$ chainloop integration registered add oci-registry \
# i.e us-east1-docker.pkg.dev/my-project/chainloop-cas-devel
--opt repository=[region]-docker.pkg.dev/[my-project]/[my-repository] \
--opt username=_json_key \
--opt "password=$(cat service-account.json)"
```

### GitHub packages

Using personal access token with write:packages permissions https://github.com/settings/tokens

```console
$ chainloop integration registered add oci-registry \
# i.e ghcr.io/chainloop-dev/chainloop-cas
--opt repository=ghcr.io/[username or org]/[my-repository] \
--opt username=[username] \
--opt password=[personal access token]
```

### DockerHub

Create a personal access token at https://hub.docker.com/settings/security

```console
$ chainloop integration registered add oci-registry \
--opt repository=index.docker.io/[username] \
--opt username=[username] \
--opt password=[personal access token]
```

### AWS Container Registry

Not supported at the moment
222 changes: 222 additions & 0 deletions app/controlplane/extensions/core/ociregistry/v1/ociregistry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
//
// Copyright 2023 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 ociregistry

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"

v1 "github.com/chainloop-dev/chainloop/app/artifact-cas/api/cas/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1"
"github.com/chainloop-dev/chainloop/internal/blobmanager/oci"
"github.com/chainloop-dev/chainloop/internal/ociauth"
"github.com/go-kratos/kratos/v2/log"
cr_v1 "github.com/google/go-containerregistry/pkg/v1"
)

type Integration struct {
*sdk.FanOutIntegration
}

// 1 - API schema definitions
type registrationRequest struct {
// Repository is not fully URI compliant and hence can not be validated with jsonschema
Repository string `json:"repository" jsonschema:"minLength=1,description=OCI repository uri and path"`
Username string `json:"username" jsonschema:"minLength=1,description=OCI repository username"`
Password string `json:"password" jsonschema:"minLength=1,description=OCI repository password"`
}

type attachmentRequest struct {
Prefix string `json:"prefix,omitempty" jsonschema:"minLength=1,description=OCI images name prefix (default chainloop)"`
}

// 2 - Configuration state
type registrationState struct {
Repository string `json:"repository"`
}

type attachmentState struct {
Prefix string `json:"prefix"`
}

func New(l log.Logger) (sdk.FanOut, error) {
base, err := sdk.NewFanOut(
&sdk.NewParams{
ID: "oci-registry",
Version: "0.1",
Description: "Send attestations to a compatible OCI registry",
Logger: l,
InputSchema: &sdk.InputSchema{
Registration: &registrationRequest{},
Attachment: &attachmentRequest{},
},
},
sdk.WithEnvelope(),
)

if err != nil {
return nil, err
}

return &Integration{base}, nil
}

// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
i.Logger.Info("registration requested")

// Extract request payload
var request *registrationRequest
if err := sdk.FromConfig(req.Payload, &request); err != nil {
return nil, fmt.Errorf("invalid registration request: %w", err)
}

// Create and validate OCI credentials
k, err := ociauth.NewCredentials(request.Repository, request.Username, request.Password)
if err != nil {
return nil, fmt.Errorf("the provided credentials are invalid")
}

// Check write permissions
b, err := oci.NewBackend(request.Repository, &oci.RegistryOptions{Keychain: k})
if err != nil {
return nil, fmt.Errorf("the provided credentials are invalid")
}

if err := b.CheckWritePermissions(context.TODO()); err != nil {
return nil, fmt.Errorf("the provided credentials don't have write permissions")
}

// They seem valid, let's store them in the configuration and credentials state
response := &sdk.RegistrationResponse{}

// a) Configuration State
rawConfig, err := sdk.ToConfig(&registrationState{
Repository: request.Repository,
})
if err != nil {
return nil, fmt.Errorf("marshalling configuration: %w", err)
}

response.Configuration = rawConfig

// b) Credentials state
response.Credentials = &sdk.Credentials{Password: request.Password, Username: request.Username}

return response, nil
}

// Attachment is executed when to attach a registered instance of this integration to a specific workflow
func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
// Extract request payload
var request *attachmentRequest
if err := sdk.FromConfig(req.Payload, &request); err != nil {
return nil, fmt.Errorf("invalid registration request: %w", err)
}

// Define the state to be stored
config, err := sdk.ToConfig(&attachmentState{Prefix: request.Prefix})
if err != nil {
return nil, fmt.Errorf("marshalling configuration: %w", err)
}

return &sdk.AttachmentResponse{Configuration: config}, nil
}

func (i *Integration) Execute(ctx context.Context, req *sdk.ExecutionRequest) error {
i.Logger.Info("execution requested")

if err := validateExecuteRequest(req); err != nil {
return fmt.Errorf("running validation: %w", err)
}

// Extract registration configuration and credentials
var registrationConfig *registrationState
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &registrationConfig); err != nil {
return fmt.Errorf("invalid registration configuration %w", err)
}

// Extract attachment configuration
var attachmentConfig *attachmentState
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &attachmentConfig); err != nil {
return fmt.Errorf("invalid attachment configuration %w", err)
}

// Create OCI backend client
credentials := req.RegistrationInfo.Credentials
k, err := ociauth.NewCredentials(registrationConfig.Repository, credentials.Username, credentials.Password)
if err != nil {
return fmt.Errorf("setting up the keychain: %w", err)
}

// Add prefix if provided
var opts = make([]oci.NewBackendOpt, 0)
if attachmentConfig.Prefix != "" {
opts = append(opts, oci.WithPrefix(attachmentConfig.Prefix))
}

ociClient, err := oci.NewBackend(registrationConfig.Repository, &oci.RegistryOptions{Keychain: k}, opts...)
if err != nil {
return fmt.Errorf("creating OCI backend %w", err)
}

i.Logger.Infow("msg", "Uploading attestation", "repo", registrationConfig.Repository, "workflowID", req.WorkflowID)

// Perform the upload of the json marshalled attestation
jsonContent, err := json.Marshal(req.Input.DSSEnvelope)
if err != nil {
return fmt.Errorf("marshaling the envelope: %w", err)
}

// Calculate digest since it will be used as CAS reference
h, _, err := cr_v1.SHA256(bytes.NewBuffer(jsonContent))
if err != nil {
return fmt.Errorf("calculating the digest: %w", err)
}

if err := ociClient.Upload(ctx, bytes.NewBuffer(jsonContent), &v1.CASResource{Digest: h.Hex, FileName: "attestation.json"}); err != nil {
return fmt.Errorf("uploading the attestation: %w", err)
}

i.Logger.Infow("msg", "Attestation uploaded", "repo", registrationConfig.Repository, "workflowID", req.WorkflowID)

return nil
}

// Validate that we are receiving an envelope
// and the credentials and state from the registration stage
func validateExecuteRequest(req *sdk.ExecutionRequest) error {
if req == nil || req.Input == nil {
return errors.New("execution input not received")
}

if req.Input.DSSEnvelope == nil {
return errors.New("execution input invalid, the envelope is empty")
}

if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil {
return errors.New("missing registration configuration")
}

if req.RegistrationInfo.Credentials == nil {
return errors.New("missing credentials")
}

return nil
}
Loading