diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index aa55e6621..7717cf934 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -31,6 +31,10 @@ RUN curl -Lo oras.tar.gz https://github.com/oras-project/oras/releases/download/ && mv oras-install/oras /usr/local/bin/ \ && rm -rf ./oras-install oras.tar.gz +ARG KUBEBUILDER_VERSION="3.8.0" +RUN curl -L https://github.com/kubernetes-sigs/kubebuilder/releases/download/v${KUBEBUILDER_VERSION}/kubebuilder_linux_amd64 -o /usr/local/bin/kubebuilder \ + && chmod +x /usr/local/bin/kubebuilder + # [Optional] Uncomment this section to install additional OS packages. RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends protobuf-compiler libprotobuf-dev diff --git a/.vscode/launch.json b/.vscode/launch.json index 421fe8cdf..3a0d8c1fa 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,6 +10,9 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/ratify", + "env": { + "RATIFY_DYNAMIC_PLUGINS": "1" + }, "args": [ "verify", "-s", @@ -22,6 +25,9 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/ratify", + "env": { + "RATIFY_DYNAMIC_PLUGINS": "1" + }, "args": [ "serve", "--http", @@ -36,7 +42,8 @@ "mode": "auto", "program": "${workspaceFolder}/cmd/ratify", "env": { - "RATIFY_LOG_LEVEL": "debug" + "RATIFY_LOG_LEVEL": "debug", + "RATIFY_DYNAMIC_PLUGINS": "1" }, "args": [ "serve", diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go new file mode 100644 index 000000000..0808c5633 --- /dev/null +++ b/api/v1alpha1/common.go @@ -0,0 +1,31 @@ +/* +Copyright 2022. + +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 v1alpha1 + +import runtime "k8s.io/apimachinery/pkg/runtime" + +// PluginSource defines the fields needed to download a plugin from an OCI Artifact source +type PluginSource struct { + // Important: Run "make" to regenerate code after modifying this file + + // OCI Artifact source to download the plugin from + Artifact string `json:"artifact,omitempty"` + + // +kubebuilder:pruning:PreserveUnknownFields + // AuthProvider to use to authenticate to the OCI Artifact source, optional + AuthProvider runtime.RawExtension `json:"authProvider,omitempty"` +} diff --git a/api/v1alpha1/store_types.go b/api/v1alpha1/store_types.go index 065bd0fc0..4d37950f9 100644 --- a/api/v1alpha1/store_types.go +++ b/api/v1alpha1/store_types.go @@ -29,6 +29,8 @@ type StoreSpec struct { Name string `json:"name,omitempty"` // Plugin path, optional Address string `json:"address,omitempty"` + // OCI Artifact source to download the plugin from, optional + Source *PluginSource `json:"source,omitempty"` // +kubebuilder:pruning:PreserveUnknownFields // Parameters of the store diff --git a/api/v1alpha1/verifier_types.go b/api/v1alpha1/verifier_types.go index ec115656e..07fc4560b 100644 --- a/api/v1alpha1/verifier_types.go +++ b/api/v1alpha1/verifier_types.go @@ -34,6 +34,9 @@ type VerifierSpec struct { // # Optional. URL/file path Address string `json:"address,omitempty"` + // OCI Artifact source to download the plugin from, optional + Source *PluginSource `json:"source,omitempty"` + // +kubebuilder:pruning:PreserveUnknownFields // Parameters for this verifier Parameters runtime.RawExtension `json:"parameters,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d99c32a8c..e51352f45 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -115,6 +115,22 @@ func (in *CertificateStoreStatus) DeepCopy() *CertificateStoreStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PluginSource) DeepCopyInto(out *PluginSource) { + *out = *in + in.AuthProvider.DeepCopyInto(&out.AuthProvider) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PluginSource. +func (in *PluginSource) DeepCopy() *PluginSource { + if in == nil { + return nil + } + out := new(PluginSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Store) DeepCopyInto(out *Store) { *out = *in @@ -177,6 +193,11 @@ func (in *StoreList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StoreSpec) DeepCopyInto(out *StoreSpec) { *out = *in + if in.Source != nil { + in, out := &in.Source, &out.Source + *out = new(PluginSource) + (*in).DeepCopyInto(*out) + } in.Parameters.DeepCopyInto(&out.Parameters) } @@ -267,6 +288,11 @@ func (in *VerifierList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VerifierSpec) DeepCopyInto(out *VerifierSpec) { *out = *in + if in.Source != nil { + in, out := &in.Source, &out.Source + *out = new(PluginSource) + (*in).DeepCopyInto(*out) + } in.Parameters.DeepCopyInto(&out.Parameters) } diff --git a/charts/ratify/crds/store-customresourcedefinition.yaml b/charts/ratify/crds/store-customresourcedefinition.yaml index 0d49145d5..b46587f17 100644 --- a/charts/ratify/crds/store-customresourcedefinition.yaml +++ b/charts/ratify/crds/store-customresourcedefinition.yaml @@ -39,10 +39,22 @@ spec: description: Parameters of this store type: object x-kubernetes-preserve-unknown-fields: true + source: + description: OCI Artifact source to download the plugin from, optional + properties: + artifact: + description: OCI Artifact source to download the plugin from + type: string + authProvider: + description: AuthProvider to use to authenticate to the OCI Artifact + source, optional + type: object + x-kubernetes-preserve-unknown-fields: true + type: object type: object status: description: StoreStatus defines the observed state of Store type: object type: object served: true - storage: true \ No newline at end of file + storage: true diff --git a/charts/ratify/crds/verifier-customresourcedefinition.yaml b/charts/ratify/crds/verifier-customresourcedefinition.yaml index a43687900..a4ba279a1 100644 --- a/charts/ratify/crds/verifier-customresourcedefinition.yaml +++ b/charts/ratify/crds/verifier-customresourcedefinition.yaml @@ -42,10 +42,22 @@ spec: description: Parameters of this verifier type: object x-kubernetes-preserve-unknown-fields: true + source: + description: OCI Artifact source to download the plugin from, optional + properties: + artifact: + description: OCI Artifact source to download the plugin from + type: string + authProvider: + description: AuthProvider to use to authenticate to the OCI Artifact + source, optional + type: object + x-kubernetes-preserve-unknown-fields: true + type: object type: object status: description: VerifierStatus defines the observed state of Verifier type: object type: object served: true - storage: true \ No newline at end of file + storage: true diff --git a/config/crd/bases/config.ratify.deislabs.io_stores.yaml b/config/crd/bases/config.ratify.deislabs.io_stores.yaml index f0476b0da..531a3621f 100644 --- a/config/crd/bases/config.ratify.deislabs.io_stores.yaml +++ b/config/crd/bases/config.ratify.deislabs.io_stores.yaml @@ -45,6 +45,18 @@ spec: description: Parameters of the store type: object x-kubernetes-preserve-unknown-fields: true + source: + description: OCI Artifact source to download the plugin from, optional + properties: + artifact: + description: OCI Artifact source to download the plugin from + type: string + authProvider: + description: AuthProvider to use to authenticate to the OCI Artifact + source, optional + type: object + x-kubernetes-preserve-unknown-fields: true + type: object type: object status: description: StoreStatus defines the observed state of Store diff --git a/config/crd/bases/config.ratify.deislabs.io_verifiers.yaml b/config/crd/bases/config.ratify.deislabs.io_verifiers.yaml index 8c305902f..6ed3ffc89 100644 --- a/config/crd/bases/config.ratify.deislabs.io_verifiers.yaml +++ b/config/crd/bases/config.ratify.deislabs.io_verifiers.yaml @@ -48,6 +48,18 @@ spec: description: Parameters for this verifier type: object x-kubernetes-preserve-unknown-fields: true + source: + description: OCI Artifact source to download the plugin from, optional + properties: + artifact: + description: OCI Artifact source to download the plugin from + type: string + authProvider: + description: AuthProvider to use to authenticate to the OCI Artifact + source, optional + type: object + x-kubernetes-preserve-unknown-fields: true + type: object type: object status: description: VerifierStatus defines the observed state of Verifier diff --git a/config/samples/config_v1alpha1_verifier_dynamic.yaml b/config/samples/config_v1alpha1_verifier_dynamic.yaml new file mode 100644 index 000000000..06d54cc16 --- /dev/null +++ b/config/samples/config_v1alpha1_verifier_dynamic.yaml @@ -0,0 +1,9 @@ +apiVersion: config.ratify.deislabs.io/v1alpha1 +kind: Verifier +metadata: + name: verifier-dynamic +spec: + name: dynamic + artifactTypes: application/vnd.ratify.spdx.v0 + source: + artifact: wabbitnetworks.azurecr.io/test/sample-verifier-plugin:v1 diff --git a/docs/reference/dynamic-plugins.md b/docs/reference/dynamic-plugins.md new file mode 100644 index 000000000..5d9b3b23c --- /dev/null +++ b/docs/reference/dynamic-plugins.md @@ -0,0 +1,78 @@ +# Dynamic Plugins + +While Ratify provides a number of built-in referrer stores and verifiers, there may be times that you need to add additional out-of-tree plugins to verify additional artifact types or check for specific conditions. Whether you've followed the [plugin authoring guide](./creating-plugins.md), or you want to use a plugin that's provided by a third party, the next step is to make your plugin available to be called at runtime. + +Ratify includes an optional dynamic plugins feature to simplify plugin distribution. This removes the need to rebuild or maintain your own Ratify container image. When enabled, plugins are stored as OCI artifacts and pulled down at runtime when they are registered via CRD. This guide will show you how to activate and configure dynamic plugins. + +## Uploading your plugin + +Upload your plugin to a registry that supports OCI artifacts. Here we're using the sample plugin located at `plugins/verifier/sample`. Replace the registry values with your own. + +```shell +CGO_ENABLED=0 go build -o=./sample ./plugins/verifier/sample +oras push myregistry.azurecr.io/sample-plugin:v1 ./sample +``` + +> It's important to build plugins with `CGO_ENABLED=0` so that they can run inside the Ratify [distroless](https://github.com/GoogleContainerTools/distroless) container + +## Ratify Configuration + +Now that you have a plugin available as an OCI artifact, it's time to enable the `RATIFY_DYNAMIC_PLUGINS` [feature flag](/docs/reference/usage.md#feature-flags). Here, we enable it via Helm chart parameter: + +```shell +# Option 1: to enable on a fresh install +helm install ratify \ + ratify/ratify --atomic \ + --set-file provider.tls.crt=${CERT_DIR}/server.crt \ + --set-file provider.tls.key=${CERT_DIR}/server.key \ + --set provider.tls.cabundle="$(cat ${CERT_DIR}/ca.crt | base64 | tr -d '\n')" \ + --set featureFlags.RATIFY_DYNAMIC_PLUGINS=true + +# Option 2: to enable on a previously-installed release +helm upgrade ratify \ + ratify/ratify --atomic \ + --reuse-values \ + --set featureFlags.RATIFY_DYNAMIC_PLUGINS=true +``` + +## Plugin Configuration + +The last step is to specify the optional `source` property to tell Ratify where to download the plugin from. Dynamic plugins use the same [auth providers](/docs/reference/oras-auth-provider.md) and options as the builtin ORAS store (ex: `azureWorkloadIdentity`, `awsEcrBasic`, `k8Secrets`) for authentication to the registry where your plugins are located. + +```yaml +apiVersion: config.ratify.deislabs.io/v1alpha1 +kind: Verifier +metadata: + name: verifier-sample +spec: + name: sample + artifactTypes: application/vnd.ratify.spdx.v0 + source: + artifact: myregistry.azurecr.io/sample-plugin:v1 + authProvider: + name: azureWorkloadIdentity +``` + +## Confirmation / Troubleshooting + +You can check the Ratify logs for more details on which plugin(s) were downloaded. Your specific commands may vary slightly based on the values you provided during chart installation. + +```shell +kubectl logs -n gatekeeper-system deployment/ratify +``` + +This will generate output similar to below, which can be used for confirmation of a successful plugin download or to aid in troubleshooting. + +```text +time="2023-01-18T16:44:46Z" level=info msg="Setting log level to info" +time="2023-01-18T16:44:46Z" level=info msg="Feature flag DYNAMIC_PLUGINS is enabled" +time="2023-01-18T16:44:46Z" level=info msg="starting crd manager" +time="2023-01-18T16:44:46Z" level=info msg="initializing executor with config file at default config path" + +time="2023-01-18T16:44:46Z" level=info msg="reconciling verifier 'verifier-dynamic'" +time="2023-01-18T16:44:46Z" level=info msg="Address was empty, setting to default path: /.ratify/plugins" +time="2023-01-18T16:44:47Z" level=info msg="selected auth provider: azureWorkloadIdentity" +time="2023-01-18T16:44:48Z" level=info msg="downloaded verifier plugin dynamic from myregistry.azurecr.io/sample-plugin:v1 to /.ratify/plugins/dynamic" +time="2023-01-18T16:44:48Z" level=info msg="verifier 'dynamic' added to verifier map" + +``` diff --git a/docs/reference/usage.md b/docs/reference/usage.md index d057b64dc..d8ad5ce1a 100644 --- a/docs/reference/usage.md +++ b/docs/reference/usage.md @@ -18,3 +18,5 @@ This page documents useful flags and options supported by Ratify Ratify may roll out new features behind feature flags, which are activated by setting the corresponding environment variable `RATIFY_=1`. A value of `1` indicates the feature is active; any other value disables the flag. + +- `RATIFY_DYNAMIC_PLUGINS`: (disabled) Enables Ratify to download plugins at runtime from an OCI registry by setting `source` on the plugin config diff --git a/httpserver/Dockerfile b/httpserver/Dockerfile index 55c51e721..ac181f181 100644 --- a/httpserver/Dockerfile +++ b/httpserver/Dockerfile @@ -35,7 +35,7 @@ ARG RATIFY_FOLDER=$HOME/.ratify/ WORKDIR / COPY --from=builder /app/out/ratify /app/ -COPY --from=builder /app/out/plugins ${RATIFY_FOLDER}/plugins +COPY --from=builder --chown=65532:65532 /app/out/plugins ${RATIFY_FOLDER}/plugins COPY --from=builder /app/config/config.json ${RATIFY_FOLDER} ENV RATIFY_CONFIG=${RATIFY_FOLDER} diff --git a/pkg/common/plugin/download.go b/pkg/common/plugin/download.go new file mode 100644 index 000000000..3b60bd606 --- /dev/null +++ b/pkg/common/plugin/download.go @@ -0,0 +1,143 @@ +/* +Copyright The Ratify 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 plugin + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/deislabs/ratify/pkg/common/oras/authprovider" + "github.com/deislabs/ratify/pkg/ocispecs" + "github.com/sirupsen/logrus" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" +) + +type PluginSource struct { + Artifact string `json:"artifact"` + AuthProvider authprovider.AuthProviderConfig `json:"authProvider,omitempty"` +} + +func ParsePluginSource(source interface{}) (PluginSource, error) { + var pluginSource PluginSource + + // parse to/from json + sourceBytes, err := json.Marshal(source) + if err != nil { + return pluginSource, err + } + + err = json.Unmarshal(sourceBytes, &pluginSource) + if err != nil { + return pluginSource, err + } + + return pluginSource, nil +} + +func DownloadPlugin(source PluginSource, targetPath string) error { + ctx := context.TODO() + + // initialize a repository + repository, err := remote.NewRepository(source.Artifact) + if err != nil { + return err + } + + repository.Client = &auth.Client{ + Client: &http.Client{Timeout: 10 * time.Second, Transport: http.DefaultTransport.(*http.Transport).Clone()}, + Header: http.Header{ + "User-Agent": {"ratify"}, + }, + Cache: auth.NewCache(), + Credential: func(ctx context.Context, registry string) (auth.Credential, error) { + authProvider, err := authprovider.CreateAuthProviderFromConfig(source.AuthProvider) + if err != nil { + return auth.EmptyCredential, err + } + + authConfig, err := authProvider.Provide(ctx, registry) + if err != nil { + return auth.EmptyCredential, err + } + + if authConfig.Username != "" || authConfig.Password != "" || authConfig.IdentityToken != "" { + return auth.Credential{ + Username: authConfig.Username, + Password: authConfig.Password, + RefreshToken: authConfig.IdentityToken, + }, nil + } + return auth.EmptyCredential, nil + }, + } + + // read the reference manifest + referenceManifestDescriptor, err := repository.Resolve(ctx, source.Artifact) + if err != nil { + return err + } + logrus.Debugf("Resolved plugin manifest: %v", referenceManifestDescriptor) + + manifestReader, err := repository.Fetch(ctx, referenceManifestDescriptor) + if err != nil { + return err + } + + manifestBytes, err := io.ReadAll(manifestReader) + if err != nil { + return err + } + + referenceManifest := ocispecs.ReferenceManifest{} + if err := json.Unmarshal(manifestBytes, &referenceManifest); err != nil { + return err + } + + // download the first blob to the target path + blobReference := fmt.Sprintf("%s@%s", source, referenceManifest.Blobs[0].Digest) + logrus.Debugf("Downloading blob %s", blobReference) + _, blobReader, err := repository.Blobs().FetchReference(ctx, blobReference) + if err != nil { + return err + } + + logrus.Debugf("writing plugin bytes to %s", targetPath) + pluginFile, err := os.Create(targetPath) + if err != nil { + return err + } + + defer pluginFile.Close() + _, err = io.Copy(pluginFile, blobReader) + if err != nil { + return err + } + + // mark the plugin as executable + logrus.Debugf("marking %s as executable", targetPath) + err = os.Chmod(targetPath, 0700) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/common/plugin/download_test.go b/pkg/common/plugin/download_test.go new file mode 100644 index 000000000..5abc3b882 --- /dev/null +++ b/pkg/common/plugin/download_test.go @@ -0,0 +1,75 @@ +/* +Copyright The Ratify 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 plugin + +import ( + "encoding/json" + "testing" + + "github.com/deislabs/ratify/api/v1alpha1" +) + +func TestParsePluginSource_HandlesJSON(t *testing.T) { + js := `{ + "name": "dynamic", + "artifactTypes": "sbom/example", + "nestedReferences": "application/vnd.cncf.notary.signature", + "source": { + "artifact": "wabbitnetworks.azurecr.io/test/sample-plugin:v1", + "authProvider": { + "name": "dockerConfig" + } + } +}` + + var verifierConfig map[string]interface{} + err := json.Unmarshal([]byte(js), &verifierConfig) + if err != nil { + t.Fatalf("failed to unmarshal verifier config: %v", err) + } + + source, err := ParsePluginSource(verifierConfig["source"]) + if err != nil { + t.Fatalf("failed to parse plugin source: %v", err) + } + + if source.Artifact != "wabbitnetworks.azurecr.io/test/sample-plugin:v1" { + t.Fatalf("unexpected artifact: %s", source.Artifact) + } + + if source.AuthProvider["name"] != "dockerConfig" { + t.Fatalf("unexpected auth provider: %s", source.AuthProvider["name"]) + } +} + +func TestParsePluginSource_HandlesCRD(t *testing.T) { + verifierConfig := v1alpha1.VerifierSpec{ + Name: "dynamic", + ArtifactTypes: "sbom/example", + Source: &v1alpha1.PluginSource{ + Artifact: "wabbitnetworks.azurecr.io/test/sample-plugin:v1", + }, + } + + source, err := ParsePluginSource(verifierConfig.Source) + if err != nil { + t.Fatalf("failed to parse plugin source: %v", err) + } + + if source.Artifact != "wabbitnetworks.azurecr.io/test/sample-plugin:v1" { + t.Fatalf("unexpected artifact: %s", source.Artifact) + } +} diff --git a/pkg/controllers/store_controller.go b/pkg/controllers/store_controller.go index fe5949ef2..4f6dcf6c8 100644 --- a/pkg/controllers/store_controller.go +++ b/pkg/controllers/store_controller.go @@ -130,6 +130,9 @@ func specToStoreConfig(storeSpec configv1alpha1.StoreSpec) (rc.StorePluginConfig } } storeConfig[types.Name] = storeSpec.Name + if storeSpec.Source != nil { + storeConfig[types.Source] = storeSpec.Source + } return storeConfig, nil } diff --git a/pkg/controllers/verifier_controller.go b/pkg/controllers/verifier_controller.go index 7b12ed46a..9e9149a75 100644 --- a/pkg/controllers/verifier_controller.go +++ b/pkg/controllers/verifier_controller.go @@ -130,6 +130,9 @@ func specToVerifierConfig(verifierSpec configv1alpha1.VerifierSpec) (vc.Verifier verifierConfig[types.Name] = verifierSpec.Name verifierConfig[types.ArtifactTypes] = verifierSpec.ArtifactTypes + if verifierSpec.Source != nil { + verifierConfig[types.Source] = verifierSpec.Source + } return verifierConfig, nil } diff --git a/pkg/referrerstore/factory/factory.go b/pkg/referrerstore/factory/factory.go index 25b111f57..9524eb183 100644 --- a/pkg/referrerstore/factory/factory.go +++ b/pkg/referrerstore/factory/factory.go @@ -19,12 +19,16 @@ import ( "errors" "fmt" "os" + "path" "strings" + pluginCommon "github.com/deislabs/ratify/pkg/common/plugin" + "github.com/deislabs/ratify/pkg/featureflag" "github.com/deislabs/ratify/pkg/referrerstore" "github.com/deislabs/ratify/pkg/referrerstore/config" "github.com/deislabs/ratify/pkg/referrerstore/plugin" "github.com/deislabs/ratify/pkg/referrerstore/types" + "github.com/sirupsen/logrus" ) var builtInStores = make(map[string]StoreFactory) @@ -57,6 +61,25 @@ func CreateStoreFromConfig(storeConfig config.StorePluginConfig, configVersion s return nil, fmt.Errorf("invalid plugin name for a store: %s", storeName) } + // if source is specified, download the plugin + if source, ok := storeConfig[types.Source]; ok { + if featureflag.DynamicPlugins.Enabled { + source, err := pluginCommon.ParsePluginSource(source) + if err != nil { + return nil, fmt.Errorf("failed to parse plugin source: %w", err) + } + + targetPath := path.Join(pluginBinDir[0], storeNameStr) + err = pluginCommon.DownloadPlugin(source, targetPath) + if err != nil { + return nil, fmt.Errorf("failed to download plugin: %w", err) + } + logrus.Infof("downloaded store plugin %s from %s to %s", storeNameStr, source.Artifact, targetPath) + } else { + logrus.Warnf("%s was specified for store plugin %s, but dynamic plugins are currently disabled", types.Source, storeNameStr) + } + } + storeFactory, ok := builtInStores[storeNameStr] if ok { return storeFactory.Create(configVersion, storeConfig) diff --git a/pkg/referrerstore/types/types.go b/pkg/referrerstore/types/types.go index 4296bd62f..e5ea55b71 100644 --- a/pkg/referrerstore/types/types.go +++ b/pkg/referrerstore/types/types.go @@ -27,6 +27,7 @@ const ( SpecVersion string = "0.1.0" Version string = "version" Name string = "name" + Source string = "source" ) const ( diff --git a/pkg/verifier/factory/factory.go b/pkg/verifier/factory/factory.go index 46101f578..d7f67204a 100644 --- a/pkg/verifier/factory/factory.go +++ b/pkg/verifier/factory/factory.go @@ -19,8 +19,11 @@ import ( "errors" "fmt" "os" + "path" "strings" + pluginCommon "github.com/deislabs/ratify/pkg/common/plugin" + "github.com/deislabs/ratify/pkg/featureflag" "github.com/deislabs/ratify/pkg/verifier" "github.com/deislabs/ratify/pkg/verifier/config" "github.com/deislabs/ratify/pkg/verifier/plugin" @@ -58,6 +61,25 @@ func CreateVerifierFromConfig(verifierConfig config.VerifierConfig, configVersio return nil, fmt.Errorf("invalid plugin name for a verifier: %s", verifierNameStr) } + // if source is specified, download the plugin + if source, ok := verifierConfig[types.Source]; ok { + if featureflag.DynamicPlugins.Enabled { + source, err := pluginCommon.ParsePluginSource(source) + if err != nil { + return nil, fmt.Errorf("failed to parse plugin source: %w", err) + } + + targetPath := path.Join(pluginBinDir[0], verifierNameStr) + err = pluginCommon.DownloadPlugin(source, targetPath) + if err != nil { + return nil, fmt.Errorf("failed to download plugin: %w", err) + } + logrus.Infof("downloaded verifier plugin %s from %s to %s", verifierNameStr, source.Artifact, targetPath) + } else { + logrus.Warnf("%s was specified for verifier plugin %s, but dynamic plugins are currently disabled", types.Source, verifierNameStr) + } + } + verifierFactory, ok := builtInVerifiers[verifierNameStr] if ok { return verifierFactory.Create(configVersion, verifierConfig) diff --git a/pkg/verifier/types/types.go b/pkg/verifier/types/types.go index 6587adcc6..db98ae296 100644 --- a/pkg/verifier/types/types.go +++ b/pkg/verifier/types/types.go @@ -28,6 +28,7 @@ const ( Name string = "name" ArtifactTypes string = "artifactTypes" NestedReferences string = "nestedReferences" + Source string = "source" ) const ( diff --git a/test/bats/cli-test.bats b/test/bats/cli-test.bats index 120dda1a4..237937b2a 100644 --- a/test/bats/cli-test.bats +++ b/test/bats/cli-test.bats @@ -47,3 +47,31 @@ load helpers run bin/ratify verify -c $RATIFY_DIR/config.json -s wabbitnetworks.azurecr.io/test/all-in-one-image:signed assert_cmd_verify_success } + +@test "dynamic plugin verifier test" { + # dynamic plugins disabled by default + run bash -c "bin/ratify verify -c $RATIFY_DIR/dynamic_plugins_config.json -s wabbitnetworks.azurecr.io/test/all-in-one-image:signed 2>&1 >/dev/null | grep 'dynamic plugins are currently disabled'" + assert_success + + # dynamic plugins enabled with feature flag + run bash -c "RATIFY_DYNAMIC_PLUGINS=1 bin/ratify verify -c $RATIFY_DIR/dynamic_plugins_config.json -s wabbitnetworks.azurecr.io/test/all-in-one-image:signed 2>&1 >/dev/null | grep 'downloaded verifier plugin dynamic from .* to .*'" + assert_success + + # ensure the plugin is downloaded and marked executable + test -x $RATIFY_DIR/plugins/dynamic + assert_success +} + +@test "dynamic plugin store test" { + # dynamic plugins disabled by default + run bash -c "bin/ratify verify -c $RATIFY_DIR/dynamic_plugins_config.json -s wabbitnetworks.azurecr.io/test/all-in-one-image:signed 2>&1 >/dev/null | grep 'dynamic plugins are currently disabled'" + assert_success + + # dynamic plugins enabled with feature flag + run bash -c "RATIFY_DYNAMIC_PLUGINS=1 bin/ratify verify -c $RATIFY_DIR/dynamic_plugins_config.json -s wabbitnetworks.azurecr.io/test/all-in-one-image:signed 2>&1 >/dev/null | grep 'downloaded store plugin dynamicstore from .* to .*'" + assert_success + + # ensure the plugin is downloaded and marked executable + test -x $RATIFY_DIR/plugins/dynamicstore + assert_success +} diff --git a/test/bats/test.bats b/test/bats/test.bats index 54ce95855..71768dbc6 100644 --- a/test/bats/test.bats +++ b/test/bats/test.bats @@ -209,6 +209,48 @@ SLEEP_TIME=1 wait_for_process ${WAIT_TIME} ${SLEEP_TIME} "kubectl replace --namespace=gatekeeper-system -f currentConfig.yaml" } +@test "dynamic plugins disabled test" { + teardown() { + wait_for_process ${WAIT_TIME} ${SLEEP_TIME} 'kubectl delete verifiers.config.ratify.deislabs.io/verifier-dynamic --namespace default --ignore-not-found=true' + } + + start=$(date --iso-8601=seconds) + latestpod=$(kubectl -n gatekeeper-system get pod -l=app.kubernetes.io/name=ratify --sort-by=.metadata.creationTimestamp -o=name | tail -n 1) + + run kubectl apply -f ./config/samples/config_v1alpha1_verifier_dynamic.yaml + sleep 5 + + run bash -c "kubectl -n gatekeeper-system logs $latestpod --since-time=$start | grep 'dynamic plugins are currently disabled'" + assert_success +} + +@test "dynamic plugins enabled test" { + # only run this test against a live cluster + if [[ -z "${AKS_NAME}" ]]; then + skip "helm upgrade fails in the pipeline on a 2-core runner" + fi + + # ensure that the chart deployment is reset to a clean state for other tests + teardown() { + wait_for_process ${WAIT_TIME} ${SLEEP_TIME} 'kubectl delete verifiers.config.ratify.deislabs.io/verifier-dynamic --namespace default --ignore-not-found=true' + pod=$(kubectl -n gatekeeper-system get pod -l=app.kubernetes.io/name=ratify --sort-by=.metadata.creationTimestamp -o=name | tail -n 1) + helm upgrade --atomic --namespace gatekeeper-system --reuse-values --set featureFlags.RATIFY_DYNAMIC_PLUGINS=false ratify ./charts/ratify + wait_for_process ${WAIT_TIME} ${SLEEP_TIME} 'kubectl -n gatekeeper-system delete $pod --force --grace-period=0' + } + + # enable dynamic plugins + helm upgrade --atomic --namespace gatekeeper-system --reuse-values --set featureFlags.RATIFY_DYNAMIC_PLUGINS=true ratify ./charts/ratify + sleep 5 + latestpod=$(kubectl -n gatekeeper-system get pod -l=app.kubernetes.io/name=ratify --sort-by=.metadata.creationTimestamp -o=name | tail -n 1) + + run kubectl apply -f ./config/samples/config_v1alpha1_verifier_dynamic.yaml + sleep 5 + + # parse the logs for the newly created ratify pod + run bash -c "kubectl -n gatekeeper-system logs $latestpod | grep 'downloaded verifier plugin dynamic from .* to .*'" + assert_success +} + @test "validate mutation tag to digest" { run kubectl apply -f ./library/default/template.yaml assert_success diff --git a/test/bats/tests/config/dynamic_plugins_config.json b/test/bats/tests/config/dynamic_plugins_config.json new file mode 100644 index 000000000..fd0b0db65 --- /dev/null +++ b/test/bats/tests/config/dynamic_plugins_config.json @@ -0,0 +1,63 @@ +{ + "store": { + "version": "1.0.0", + "plugins": [ + { + "name": "oras", + "cosignEnabled": true + }, + { + "name": "dynamicstore", + "source": { + "artifact": "wabbitnetworks.azurecr.io/test/sample-store-plugin:v1" + } + } + ] + }, + "policy": { + "version": "1.0.0", + "plugin": { + "name": "configPolicy" + } + }, + "verifier": { + "version": "1.0.0", + "plugins": [ + { + "name": "dynamic", + "artifactTypes": "sbom/example", + "nestedReferences": "application/vnd.cncf.notary.signature", + "source": { + "artifact": "wabbitnetworks.azurecr.io/test/sample-verifier-plugin:v1" + } + }, + { + "name": "notaryv2", + "artifactTypes": "application/vnd.cncf.notary.signature", + "verificationCerts": [ + "~/.ratify/ratify-certs/notary/wabbit-networks.io.crt" + ], + "trustPolicyDoc": { + "version": "1.0", + "trustPolicies": [ + { + "name": "default", + "registryScopes": [ + "*" + ], + "signatureVerification": { + "level": "strict" + }, + "trustStores": [ + "ca:certs" + ], + "trustedIdentities": [ + "*" + ] + } + ] + } + } + ] + } +}