diff --git a/app/cli/cmd/attached_integration_add.go b/app/cli/cmd/attached_integration_add.go index 35916412e..b976e13c0 100644 --- a/app/cli/cmd/attached_integration_add.go +++ b/app/cli/cmd/attached_integration_add.go @@ -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) @@ -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 } diff --git a/app/cli/cmd/attached_integration_list.go b/app/cli/cmd/attached_integration_list.go index feddc2f38..415051de7 100644 --- a/app/cli/cmd/attached_integration_list.go +++ b/app/cli/cmd/attached_integration_list.go @@ -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() } diff --git a/app/cli/cmd/registered_integration_add.go b/app/cli/cmd/registered_integration_add.go index b3300d52d..3dbac92a7 100644 --- a/app/cli/cmd/registered_integration_add.go +++ b/app/cli/cmd/registered_integration_add.go @@ -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 @@ -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 } @@ -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) } diff --git a/app/controlplane/extensions/core/dependencytrack/v1/extension.go b/app/controlplane/extensions/core/dependencytrack/v1/extension.go index c493b2920..8b130bab3 100644 --- a/app/controlplane/extensions/core/dependencytrack/v1/extension.go +++ b/app/controlplane/extensions/core/dependencytrack/v1/extension.go @@ -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{ diff --git a/app/controlplane/extensions/core/ociregistry/v1/README.md b/app/controlplane/extensions/core/ociregistry/v1/README.md new file mode 100644 index 000000000..1a1012016 --- /dev/null +++ b/app/controlplane/extensions/core/ociregistry/v1/README.md @@ -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 diff --git a/app/controlplane/extensions/core/ociregistry/v1/ociregistry.go b/app/controlplane/extensions/core/ociregistry/v1/ociregistry.go new file mode 100644 index 000000000..e9a9e4ed2 --- /dev/null +++ b/app/controlplane/extensions/core/ociregistry/v1/ociregistry.go @@ -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: ®istrationRequest{}, + 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(®istrationState{ + 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, ®istrationConfig); 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 +} diff --git a/app/controlplane/extensions/core/ociregistry/v1/ociregistry_test.go b/app/controlplane/extensions/core/ociregistry/v1/ociregistry_test.go new file mode 100644 index 000000000..4c9acacf0 --- /dev/null +++ b/app/controlplane/extensions/core/ociregistry/v1/ociregistry_test.go @@ -0,0 +1,128 @@ +// +// 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 ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateRegistrationInput(t *testing.T) { + testCases := []struct { + name string + input map[string]interface{} + errMsg string + }{ + { + name: "not ok, missing properties", + input: map[string]interface{}{}, + errMsg: "missing properties: 'repository', 'username', 'password'", + }, + { + name: "not ok, random properties", + input: map[string]interface{}{"foo": "bar"}, + errMsg: "additionalProperties 'foo' not allowed", + }, + { + name: "ok, all properties", + input: map[string]interface{}{"repository": "repo.io", "username": "u", "password": "p"}, + }, + { + name: "not ok, empty repository", + input: map[string]interface{}{"repository": "", "username": "u", "password": "p"}, + errMsg: "length must be >= 1, but got 0", + }, + { + name: "not ok, empty username", + input: map[string]interface{}{"repository": "r", "username": "", "password": "p"}, + errMsg: "length must be >= 1, but got 0", + }, + { + name: "not ok, empty password", + input: map[string]interface{}{"repository": "r", "username": "u", "password": ""}, + errMsg: "length must be >= 1, but got 0", + }, + } + + integration, err := New(nil) + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, err := json.Marshal(tc.input) + require.NoError(t, err) + + err = integration.ValidateRegistrationRequest(payload) + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateAttachmentInput(t *testing.T) { + testCases := []struct { + name string + input map[string]interface{} + errMsg string + }{ + { + name: "ok, no properties", + input: map[string]interface{}{}, + }, + { + name: "not ok, random properties", + input: map[string]interface{}{"foo": "bar"}, + errMsg: "additionalProperties 'foo' not allowed", + }, + { + name: "not ok, set but empty prefix", + input: map[string]interface{}{"prefix": ""}, + errMsg: "length must be >= 1, but got 0", + }, + { + name: "valid request", + input: map[string]interface{}{"prefix": "p"}, + }, + } + + integration, err := New(nil) + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, err := json.Marshal(tc.input) + require.NoError(t, err) + + err = integration.ValidateAttachmentRequest(payload) + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNewIntegration(t *testing.T) { + _, err := New(nil) + assert.NoError(t, err) +} diff --git a/app/controlplane/extensions/core/smtp/v1/README.md b/app/controlplane/extensions/core/smtp/v1/README.md index caf66ea3c..76c8070d4 100644 --- a/app/controlplane/extensions/core/smtp/v1/README.md +++ b/app/controlplane/extensions/core/smtp/v1/README.md @@ -8,13 +8,13 @@ In the following example, we will use the AWS SES service. 1. To get started, you need to register the extension in your Chainloop organization. ``` -chainloop integration registered add smtp --options user=AHDHSYEE7e73,password=kjsdfda8asd****,host=email-smtp.us-east-1.amazonaws.com,port=587,to=platform-team@example.com,from=notifier@example.com +chainloop integration registered add smtp --opt user=AHDHSYEE7e73 --opt password=kjsdfda8asd**** --opt host=email-smtp.us-east-1.amazonaws.com --opt port=587 --opt to=platform-team@example.com --opt from=notifier@example.com ``` 2. When attaching the integration to your workflow, you have the option to specify CC: ``` -chainloop workflow integration attach --workflow $WID --integration $IID --options cc=security@example.com +chainloop integration attached add --workflow $WID --integration $IID --opt cc=security@example.com ``` cc is optional: diff --git a/app/controlplane/extensions/core/template/v1/extension.go b/app/controlplane/extensions/core/template/v1/extension.go index 76e83199f..f0065a298 100644 --- a/app/controlplane/extensions/core/template/v1/extension.go +++ b/app/controlplane/extensions/core/template/v1/extension.go @@ -29,6 +29,7 @@ type Integration struct { *sdk.FanOutIntegration } +// 1 - API schema definitions // Define the input schemas for both registration and attachment // You can annotate the struct with jsonschema tags to define the enable validations // see https://github.com/invopop/jsonschema#example for more information @@ -41,12 +42,11 @@ type attachmentRequest struct { OptionalBool bool `json:"optionalBool,omitempty" jsonschema:"description=Example of optional boolean input"` } -// You can use an arbitrary struct as a configuration state +// 2 - Configuration state +// You can use an arbitrary struct as a configuration state, this means data that you want to persist across // type registrationState struct{} // type attachmentState struct{} -// 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{ @@ -107,8 +107,7 @@ func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) // In some cases you might have sensitive information that you want to store // like for example user credentials, API Keys and so on // in such cases, you can attach them to the response as well via the Credentials field - // rawConfig, err := sdk.ToConfig(®istrationState{}) - // response.Credentials = &sdk.Credentials{Password: "deadbeef"}, + // response.Credentials = &sdk.Credentials{Password: "deadbeef"} return response, nil } @@ -129,7 +128,7 @@ func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sd // They can be accessed via // var rc *registrationState // if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil { - // return nil, errors.New("invalid registration configuration") + // return nil, fmt.Errorf("invalid registration configuration %w", err) // } // and the credentials via req.RegistrationInfo.Credentials @@ -150,7 +149,8 @@ func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sd return response, nil } -// Send the SBOM to the configured Dependency Track instance +// Execute will be instantiate when either an attestation or a material has been received +// It's up to the extension builder to differentiate between inputs func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error { i.Logger.Info("execution requested") @@ -162,13 +162,13 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro // Extract registration and attachment configuration if needed // var registrationConfig *registrationState // if err := sdk.FromConfig(req.RegistrationInfo.Configuration, ®istrationConfig); err != nil { - // return errors.New("invalid registration configuration") + // 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 errors.New("invalid attachment configuration") + // return fmt.Errorf("invalid attachment configuration %w", err) // } // START CUSTOM LOGIC diff --git a/app/controlplane/extensions/extensions.go b/app/controlplane/extensions/extensions.go index f855b1951..5a67f7a9c 100644 --- a/app/controlplane/extensions/extensions.go +++ b/app/controlplane/extensions/extensions.go @@ -19,6 +19,7 @@ import ( "fmt" "github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/dependencytrack/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/ociregistry/v1" "github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/smtp/v1" "github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1" "github.com/chainloop-dev/chainloop/internal/servicelogger" @@ -34,6 +35,7 @@ func Load(l log.Logger) (sdk.AvailableExtensions, error) { toEnable := []sdk.FanOutFactory{ dependencytrack.New, smtp.New, + ociregistry.New, } return doLoad(toEnable, l) diff --git a/internal/blobmanager/oci/backend.go b/internal/blobmanager/oci/backend.go index 59836d33e..12059258a 100644 --- a/internal/blobmanager/oci/backend.go +++ b/internal/blobmanager/oci/backend.go @@ -50,14 +50,28 @@ type RegistryOptions struct { Keychain Keychain } +type NewBackendOpt func(*Backend) + +func WithPrefix(prefix string) NewBackendOpt { + return func(b *Backend) { + b.prefix = prefix + } +} + const defaultPrefix = "chainloop" -func NewBackend(repository string, regOpts *RegistryOptions) (*Backend, error) { - return &Backend{ +func NewBackend(repository string, regOpts *RegistryOptions, opts ...NewBackendOpt) (*Backend, error) { + b := &Backend{ repo: repository, prefix: defaultPrefix, keychain: regOpts.Keychain, - }, nil + } + + for _, opt := range opts { + opt(b) + } + + return b, nil } // Exists check that the artifact is already present in the repository and it points to the diff --git a/internal/blobmanager/oci/backend_test.go b/internal/blobmanager/oci/backend_test.go index 6975f106b..5884e1698 100644 --- a/internal/blobmanager/oci/backend_test.go +++ b/internal/blobmanager/oci/backend_test.go @@ -64,6 +64,50 @@ func (s *testSuite) TestUpload() { }) } +func (s *testSuite) TestNewBackend() { + testCases := []struct { + name string + repo string + prefix string + opts []NewBackendOpt + wantErr bool + }{ + { + name: "default prefix", + repo: "localhost:5000", + opts: []NewBackendOpt{}, + }, + { + name: "custom prefix", + repo: "localhost:5000", + prefix: "custom-prefix", + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + var opts = make([]NewBackendOpt, 0) + if tc.prefix != "" { + opts = append(opts, WithPrefix(tc.prefix)) + } + + got, err := NewBackend(tc.repo, &RegistryOptions{}, opts...) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, tc.repo, got.repo) + if tc.prefix == "" { + assert.Equal(t, defaultPrefix, got.prefix) + } else { + assert.Equal(t, tc.prefix, got.prefix) + } + } + }) + } +} + func (s *testSuite) TestExists() { assert := assert.New(s.T()) // Valid image